diff --git a/.clang-tidy b/.clang-tidy index 474ab02..316e6a9 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -6,6 +6,21 @@ # doctest.h. Running live through clangd, that floods the editor with # diagnostics you cannot act on. Scoped to this project's own source tree # instead; third_party/ is a sibling directory and so is excluded by construction. +# Zwei projektspezifische Abschaltungen, begruendet: +# +# -misc-no-recursion Ein Recursive-Descent-Parser IST wechselseitig +# rekursiv (libpda/calculator.cpp). Das ist die +# Bauform, nicht ein Versehen; die Tiefe ist durch +# die Klammertiefe der Eingabe begrenzt. +# +# -performance-enum-size Schlaegt bei JEDEM enum an und will std::uint8_t +# als Basistyp. Gewinn: drei Byte in einem +# Rueckgabewert. Preis: eine ABI-Festlegung und +# Laerm in jeder Datei. +# +# ACHTUNG: Diese Kommentare stehen hier und NICHT im Checks-Block darunter. +# 'Checks: >' ist ein YAML-Folded-Scalar -- darin ist '#' gewoehnlicher Text +# und wuerde als Checkname in die Liste wandern. Checks: > bugprone-*, cert-*, @@ -20,7 +35,9 @@ Checks: > -readability-braces-around-statements, -cert-err33-c, -misc-include-cleaner, - -readability-implicit-bool-conversion + -readability-implicit-bool-conversion, + -misc-no-recursion, + -performance-enum-size CheckOptions: - key: bugprone-easily-swappable-parameters.MinimumLength diff --git a/CMakeLists.txt b/CMakeLists.txt index a811db1..a0e131a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,180 +1,67 @@ # --------------------------------------------------------------------------- -# playground +# playground - Superprojekt # -# Struktur nach P1204R0 "Canonical Project Structure": -# playground/playground/ Quellcode UND Header nebeneinander (kein include/ + src/) -# playground/tests/ nur Integrationstests gegen die oeffentliche API -# *.test.c/.cpp Unit-Tests direkt neben dem getesteten Modul +# Dieses Verzeichnis ist KEIN Projekt mit eigenem Quellcode. Es haelt nur zwei +# eigenstaendige Projekte zusammen: # -# Include-Konvention: eigene Header immer , nie "foo.h". -# Spitze Klammern durchsuchen nur die -I-Pfade, niemals das Verzeichnis der -# inkludierenden Datei -- ein fehlender Header fliegt damit sofort auf, statt -# dass zufaellig ein gleichnamiger lokaler Header gefunden wird. +# libpda/ die Bibliothek -> libpda.a bzw. libpda.dylib +# pda/ die Anwendung -> bin/pda # -# Extensions: .h/.c fuer C, .hpp/.cpp fuer C++. Die Extension sagt also die -# Sprache -- in einem gemischten Projekt ist das der Punkt. +# P1204R0 verlangt genau diese Trennung ("If a project consists of a library +# and an executable, then they should be split into separate projects"). Der +# Grund ist nicht Ordnungsliebe: die Bibliothek muss OHNE die Anwendung +# baubar, testbar und installierbar sein, sonst ist sie keine Bibliothek, +# sondern ein Unterverzeichnis. +# +# Beide Teilprojekte lassen sich einzeln konfigurieren: +# +# cmake -S libpda -B build/nur-lib && cmake --build build/nur-lib +# +# Das ist die Probe aufs Exempel. Wenn das bricht, hat die Bibliothek eine +# versteckte Abhaengigkeit zur Anwendung. # --------------------------------------------------------------------------- # 3.28 = die CMake-Version aus Ubuntu 24.04 LTS. NICHT auf die lokale 4.4 -# hochziehen: genau das macht hello-cpp (4.4), und das laesst sich auf keinem -# LTS-Server mehr konfigurieren. +# hochziehen: das laesst sich auf keinem LTS-Server mehr konfigurieren. cmake_minimum_required(VERSION 3.28) project(playground VERSION 0.1.0 - DESCRIPTION "Experimentierprojekt nach P1204R0" + DESCRIPTION "PDA: Bibliothek und Anwendung" LANGUAGES C CXX) +# CMAKE_MODULE_PATH ist die Suchliste fuer include() und +# find_package( MODULE). Ohne diese Zeile findet include(Warnings) +# nichts. list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -# --------------------------------------------------------------------------- -# Sprachstandards: C23 + C++23, keine GNU-Extensions -# --------------------------------------------------------------------------- -set(CMAKE_C_STANDARD 23) -set(CMAKE_C_STANDARD_REQUIRED ON) -set(CMAKE_C_EXTENSIONS OFF) - -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# --------------------------------------------------------------------------- -# compile_commands.json: HIER, nicht auf der Kommandozeile. -# Muss vor dem ersten Target stehen, sonst bekommt clangd eine veraltete Datei. -# --------------------------------------------------------------------------- -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - -# C++20-Modul-Scanning aus. Sonst schreibt Ninja bei CXX_STANDARD 23 ein -# "@CMakeFiles/.dir/.o.modmap" in jeden Compile-Command, und -# clangd stolpert ueber die Response-Datei, sobald build/ geleert wurde. -set(CMAKE_CXX_SCAN_FOR_MODULES OFF) - -# Ohne explizites CMAKE_BUILD_TYPE waeren alle -O/-g-Flags leer. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) -endif() - -# Binaries an einen vorhersagbaren Ort, damit die File-API-Artefakte und der -# Neovim-Target-Picker stabile Pfade sehen. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -# --------------------------------------------------------------------------- -# ccache, wenn vorhanden. Ueber COMPILER_LAUNCHER, NICHT ueber die -# Compiler-Shims in ccaches libexec -- die faengt jeden Compiler-Aufruf des -# gesamten Systems ab. -# --------------------------------------------------------------------------- -if(NOT CMAKE_C_COMPILER_LAUNCHER AND NOT CMAKE_CXX_COMPILER_LAUNCHER) - find_program(CCACHE_PROGRAM ccache) - if(CCACHE_PROGRAM) - set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") - set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") - message(STATUS "ccache: ${CCACHE_PROGRAM}") - endif() -endif() - +include(ProjectDefaults) include(Warnings) include(Sanitizers) # --------------------------------------------------------------------------- -# Kernbibliothek - neue Dateien hier manuell eintragen (kein GLOB). +# Optionen. option() legt einen Cache-Eintrag an, den man mit -D ueberschreibt: # -# GLOB waere bequem, aber CMake merkt nicht, wenn eine Datei dazukommt: der -# Build bleibt gruen und die neue Datei ist einfach nicht dabei. +# cmake --preset debug -DPDA_BUILD_TESTS=OFF # -# *.test.c und *.test.cpp gehoeren NICHT hierher (P1204R0 Regel 7.1) -- die -# werden unten zu eigenen Executables. +# BUILD_SHARED_LIBS ist eine CMake-Konvention: add_library() ohne STATIC/SHARED +# richtet sich danach. Deshalb hier NICHT selbst erfinden, sondern genau diesen +# Namen benutzen -- jedes Werkzeug da draussen kennt ihn. # --------------------------------------------------------------------------- -set(PLAYGROUND_SOURCES - playground/counter.c - playground/notebook.cpp -) +option(BUILD_SHARED_LIBS "Bibliothek als Shared Library bauen" OFF) +option(PDA_BUILD_TESTS "Unit- und Integrationstests bauen" ON) -set(PLAYGROUND_HEADERS - playground/counter.h - playground/notebook.hpp - playground/details/bits.h -) - -add_library(playground_core STATIC ${PLAYGROUND_SOURCES} ${PLAYGROUND_HEADERS}) - -# Der Include-Root ist das PROJEKTWURZELVERZEICHNIS, nicht playground/playground. -# Nur so loest auf playground/playground/counter.h auf. -target_include_directories(playground_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") - -target_link_libraries(playground_core PRIVATE playground_warnings) - -# --------------------------------------------------------------------------- -# Programm -# --------------------------------------------------------------------------- -add_executable(playground playground/main.cpp) -target_link_libraries(playground PRIVATE playground_core playground_warnings) - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- -enable_testing() - -# Fehlt third_party/, bleibt das Projekt konfigurierbar (clangd funktioniert) -# und nur die Unit-Test-Targets entfallen. -set(PLAYGROUND_HAVE_UNITY FALSE) -set(PLAYGROUND_HAVE_DOCTEST FALSE) -if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/unity/unity.c") - set(PLAYGROUND_HAVE_UNITY TRUE) -endif() -if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/doctest/doctest.h") - set(PLAYGROUND_HAVE_DOCTEST TRUE) +# enable_testing() gehoert ins OBERSTE CMakeLists.txt und nirgendwo sonst. +# Nur hier legt es die CTestTestfile.cmake an, die ctest im Wurzel-Build- +# verzeichnis sucht. +if(PDA_BUILD_TESTS) + enable_testing() endif() -if(PLAYGROUND_HAVE_UNITY OR PLAYGROUND_HAVE_DOCTEST) - add_subdirectory(third_party) -else() - message(WARNING "third_party/ ist leer -- keine Unit-Test-Targets.") -endif() - -# Unit-Test in C: .test.c -> Executable .test -> CTest -# "unity.". Der neotest-Adapter in Neovim verlaesst sich auf genau -# diesen Namen. -function(playground_add_unity_test module) - if(NOT PLAYGROUND_HAVE_UNITY) - return() - endif() - set(target "${module}.test") - add_executable(${target} "playground/${module}.test.c") - target_link_libraries(${target} PRIVATE playground_core unity playground_warnings) - add_test(NAME "unity.${module}" COMMAND ${target}) -endfunction() - -# Unit-Test in C++: .test.cpp. doctest_discover_tests() ruft die -# Executable beim Build mit --list-test-cases auf und legt EINEN CTest-Eintrag -# pro TEST_CASE an. Das ist Pflicht, nicht Kosmetik: neotest-ctest kann -# Testnamen nur so auf Quellpositionen abbilden. -function(playground_add_doctest_test module) - if(NOT PLAYGROUND_HAVE_DOCTEST) - return() - endif() - set(target "${module}.test") - add_executable(${target} "playground/${module}.test.cpp") - target_link_libraries(${target} PRIVATE playground_core doctest playground_warnings) - doctest_discover_tests(${target} TEST_PREFIX "doctest.") -endfunction() - -playground_add_unity_test(counter) -playground_add_doctest_test(notebook) - -# Integrationstests (P1204R0 Regel 7.2): eigenes tests/-Verzeichnis, laufen -# gegen die oeffentliche API -- also genau gegen das, was ein Benutzer bekommt. -add_subdirectory(tests) - -# --------------------------------------------------------------------------- -# Install -# --------------------------------------------------------------------------- -install(TARGETS playground RUNTIME DESTINATION bin) -install(DIRECTORY playground/ - DESTINATION "include/playground" - FILES_MATCHING - PATTERN "*.h" - PATTERN "*.hpp" - PATTERN "private" EXCLUDE) +add_subdirectory(third_party) +add_subdirectory(libpda) +add_subdirectory(pda) message(STATUS "playground ${PROJECT_VERSION} | C${CMAKE_C_STANDARD} C++${CMAKE_CXX_STANDARD} | " - "${CMAKE_BUILD_TYPE} | ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") + "${CMAKE_BUILD_TYPE} | ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION} | " + "shared=${BUILD_SHARED_LIBS} tests=${PDA_BUILD_TESTS}") diff --git a/CMakePresets.json b/CMakePresets.json index 0c95a43..95f6c4d 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -34,7 +34,7 @@ "displayName": "Debug + AddressSanitizer + UBSan", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "PLAYGROUND_SANITIZE": "address,undefined" + "PDA_SANITIZE": "address,undefined" } }, diff --git a/README.md b/README.md index 6639604..b3d7d2e 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ # playground -Experimentierprojekt. Struktur nach -[P1204R0 "Canonical Project Structure"](https://wg21.link/p1204r0), Konventionen -und Build-Setup identisch zu `mydb` und `cxx_scaffold_cli`. +Ein kleiner PDA — Kontakte, Taschenrechner, Notizen, Dateiexplorer — als +Übungsprojekt für die Bau- und Projektstruktur, die dahintersteht. -Der Punkt dieses Repos ist die Form, nicht der Inhalt: `counter` (C) und -`notebook` (C++) sind Gerüst-Module, die jede Regel einmal an lauffähigem Code -zeigen. Zum Experimentieren werden sie ersetzt. - -## Layout +Aufgeteilt nach [P1204R0 „Canonical Project +Structure"](https://wg21.link/p1204r0) in **zwei eigenständige Projekte**: ``` -playground/ -├── playground/ Quellcode UND Header nebeneinander -│ ├── counter.h/.c C-Modul, opakes Struct, extern "C" -│ ├── counter.test.c Unit-Test daneben (Unity) -│ ├── notebook.hpp/.cpp C++-Modul, namespace playground -│ ├── notebook.test.cpp Unit-Test daneben (doctest) -│ ├── main.cpp -│ └── details/ Implementation Details, nicht public API -├── tests/basics/ Integrationstest, nur öffentliche API -├── third_party/ vendortes Unity + doctest, nie editieren -├── cmake/ Warnings.cmake, Sanitizers.cmake -└── docs/ CONVENTIONS.md, ADRs +playground/ Superprojekt, kein eigener Quellcode +├── libpda/ die Bibliothek -> libpda.a +│ ├── libpda/ +│ │ ├── textfile.h/.c C: Datei-I/O und Feld-Escaping +│ │ ├── calculator.hpp/.cpp Ausdrucksparser (Recursive Descent) +│ │ ├── contact.hpp/.cpp Kontaktverwaltung mit Persistenz +│ │ ├── editor.hpp/.cpp zeilenorientierter Textpuffer +│ │ ├── explorer.hpp/.cpp Verzeichnisnavigation +│ │ ├── *.test.c/.cpp Unit-Tests, direkt neben dem Modul +│ │ └── details/ Implementation Details +│ └── tests/basics/ Integrationstest, nur öffentliche API +├── pda/ die Anwendung -> bin/pda +│ ├── pda/shell.hpp/.cpp Kommandoverarbeitung, ohne Ein-/Ausgabe +│ ├── pda/main.cpp REPL und Stapelbetrieb +│ └── tests/session/ Integrationstest einer ganzen Sitzung +├── examples/consumer/ fremdes Projekt, benutzt find_package(pda) +├── cmake/ ProjectDefaults, Warnings, Sanitizers, Config-Vorlage +├── third_party/ vendortes Unity + doctest, nie editieren +├── tools/check-install.sh prüft die Export-Kette Ende-zu-Ende +└── docs/ CMAKE.md, CONVENTIONS.md, ADRs ``` -Kein `include/` + `src/`. Eigene Header immer ``, nie -`"counter.h"` — das ist P1204R0s wichtigste Einzelregel. +**Warum getrennte Projekte:** P1204R0 verlangt es, und der praktische Grund +ist, dass die Bibliothek ohne die Anwendung baubar, testbar und installierbar +sein muss — sonst ist sie keine Bibliothek, sondern ein Unterverzeichnis. ## Bauen @@ -34,18 +39,73 @@ Kein `include/` + `src/`. Eigene Header immer ``, nie cmake --preset debug cmake --build --preset debug ctest --preset debug -./build/debug/bin/playground +./build/debug/bin/pda ``` Presets: `debug`, `release`, `asan-ubsan`, `apple-clang`, `homebrew-gcc`, -`linux-gcc`. Details in [docs/CONVENTIONS.md](docs/CONVENTIONS.md). +`linux-gcc`. -## Ein neues Modul hinzufügen +## Benutzen -1. `playground/.hpp` + `.cpp` anlegen (bzw. `.h`/`.c` für C). -2. Beide in `CMakeLists.txt` bei `PLAYGROUND_SOURCES` / `PLAYGROUND_HEADERS` - eintragen — **kein `file(GLOB)`**, sonst bleibt der Build grün und die Datei - fehlt einfach. -3. `playground/.test.cpp` daneben, dann - `playground_add_doctest_test()` aufrufen (C: `.test.c` und - `playground_add_unity_test()`). +Interaktiv: + +``` +$ pda +pda 0.1.0 -- 'help' zeigt die Kommandos, 'quit' beendet. +playground> contact add "Anna Schmidt" 0151 anna@example.org meine Schwester +'Anna Schmidt' angelegt (1 Kontakte) +playground> calc (2+3)*4 - 10/2 +15 +playground> note add Anna anrufen +Zeile 1 angelegt +playground> contact save kontakte.tsv +1 Kontakte nach kontakte.tsv geschrieben +``` + +Im Stapelbetrieb — ein Kommando, dann Ende: + +```sh +pda calc "2^10" # 1024 +pda contact list +``` + +Der Exit-Code ist 1, wenn das Kommando scheitert. Fehler gehen nach stderr. + +## Als Bibliothek benutzen + +```sh +cmake -S libpda -B build/lib -DCMAKE_INSTALL_PREFIX=/pfad +cmake --build build/lib && cmake --install build/lib +``` + +Dann im eigenen Projekt: + +```cmake +find_package(pda 0.1 REQUIRED) +target_link_libraries(mein_programm PRIVATE pda::pda) +``` + +```cpp +#include + +const auto value = pda::evaluate("(1 + 2) * 3 ^ 2"); +if (value) std::println("{}", *value); +``` + +Ein vollständiges Beispiel steht in [examples/consumer/](examples/consumer/); +`tools/check-install.sh` baut es gegen eine frisch installierte Bibliothek und +ist damit der Test für den Export-Mechanismus. + +## Dokumentation + +- **[docs/CMAKE.md](docs/CMAKE.md)** — CMake an diesem Projekt erklärt: + Target-Modell, `PUBLIC`/`PRIVATE`/`INTERFACE`, Generator-Ausdrücke, + install/export, Symbolsichtbarkeit, „wann mache ich was?" +- [docs/CONVENTIONS.md](docs/CONVENTIONS.md) — Stil- und Strukturregeln +- [docs/adr/](docs/adr/) — Architekturentscheidungen mit Begründung + +## Stand + +71 Tests (Unity für C, doctest für C++, zwei Integrationstreiber ohne +Framework). Grün unter Homebrew-clang 22, Apple clang und GCC 16, jeweils +statisch und als Shared Library, mit und ohne ASan/UBSan. diff --git a/cmake/ProjectDefaults.cmake b/cmake/ProjectDefaults.cmake new file mode 100644 index 0000000..8fd1566 --- /dev/null +++ b/cmake/ProjectDefaults.cmake @@ -0,0 +1,76 @@ +# --------------------------------------------------------------------------- +# Gemeinsame Grundeinstellungen fuer alle Teilprojekte. +# +# Diese Datei wird von zwei Seiten eingebunden: +# * vom Superprojekt (CMakeLists.txt im Wurzelverzeichnis), oder +# * von einem Teilprojekt, das ALLEIN konfiguriert wird +# (cmake -S libpda -B build/lib). +# +# Deshalb ist alles hier idempotent: zweimal einbinden darf nichts kaputt +# machen. Das ist keine Vorsicht, sondern Voraussetzung -- ohne das koennte +# man libpda nicht einzeln bauen. +# --------------------------------------------------------------------------- + +include_guard(GLOBAL) + +# --------------------------------------------------------------------------- +# Sprachstandards: C23 + C++23, keine GNU-Extensions. +# +# Das sind ganz normale Variablen, KEINE Targeteigenschaften. CMake liest sie +# in dem Moment, in dem ein Target angelegt wird, und schreibt den Wert dann +# in das Target. Wer sie NACH add_library() setzt, aendert nichts mehr -- +# einer der haeufigsten Anfaengerfehler. +# --------------------------------------------------------------------------- +set(CMAKE_C_STANDARD 23) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# compile_commands.json fuer clangd. Muss vor dem ersten Target stehen. +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# C++20-Modul-Scanning aus. Sonst schreibt Ninja bei CXX_STANDARD 23 ein +# "@CMakeFiles/.dir/.o.modmap" in jeden Compile-Command, und +# clangd stolpert ueber die Response-Datei, sobald build/ geleert wurde. +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) + +# Ohne explizites CMAKE_BUILD_TYPE waeren alle -O/-g-Flags leer. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE) +endif() + +# Alle Binaries an einen vorhersagbaren Ort. Ohne das landet jede Executable +# im Verzeichnis ihres CMakeLists.txt, und "wo ist mein Programm" wird zur +# Suchaufgabe. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") + +# Bei Shared Libraries: Symbole sind per Default unsichtbar und muessen +# ausdruecklich exportiert werden (siehe generate_export_header in +# libpda/CMakeLists.txt). Das ist unter Windows ohnehin Pflicht -- es hier +# auch unter Unix zu erzwingen, faengt Portabilitaetsfehler frueh. +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +# Damit eine gebaute Shared Library aus dem Build-Baum heraus lauffaehig ist, +# ohne DYLD_LIBRARY_PATH zu setzen. +set(CMAKE_BUILD_RPATH_USE_ORIGIN ON) + +# --------------------------------------------------------------------------- +# ccache, wenn vorhanden. Ueber COMPILER_LAUNCHER, NICHT ueber die +# Compiler-Shims in ccaches libexec -- die faengt jeden Compiler-Aufruf des +# gesamten Systems ab. +# --------------------------------------------------------------------------- +if(NOT CMAKE_C_COMPILER_LAUNCHER AND NOT CMAKE_CXX_COMPILER_LAUNCHER) + find_program(CCACHE_PROGRAM ccache) + if(CCACHE_PROGRAM) + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + message(STATUS "ccache: ${CCACHE_PROGRAM}") + endif() +endif() diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake index 4174c09..9fb7050 100644 --- a/cmake/Sanitizers.cmake +++ b/cmake/Sanitizers.cmake @@ -1,5 +1,5 @@ # --------------------------------------------------------------------------- -# Sanitizers, ueber -DPLAYGROUND_SANITIZE=address,undefined aktiviert +# Sanitizers, ueber -DPDA_SANITIZE=address,undefined aktiviert # (das asan-ubsan-Preset setzt genau das). # # Warum das gerade hier zaehlt: in einem Playground steht oft genau der Code, @@ -8,17 +8,19 @@ # assert-Test sieht das nie, ASan sofort. # --------------------------------------------------------------------------- -set(PLAYGROUND_SANITIZE "" CACHE STRING +include_guard(GLOBAL) + +set(PDA_SANITIZE "" CACHE STRING "Komma-getrennte Sanitizer-Liste, z.B. address,undefined") -if(NOT PLAYGROUND_SANITIZE STREQUAL "") +if(NOT PDA_SANITIZE STREQUAL "") if(NOT (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")) - message(WARNING "PLAYGROUND_SANITIZE wird von ${CMAKE_CXX_COMPILER_ID} nicht unterstuetzt") + message(WARNING "PDA_SANITIZE wird von ${CMAKE_CXX_COMPILER_ID} nicht unterstuetzt") return() endif() set(_flags - -fsanitize=${PLAYGROUND_SANITIZE} + -fsanitize=${PDA_SANITIZE} # Ohne Frame-Pointer sind die ASan-Stacks unbrauchbar. -fno-omit-frame-pointer @@ -27,7 +29,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "") -fno-optimize-sibling-calls ) - if(PLAYGROUND_SANITIZE MATCHES "undefined") + if(PDA_SANITIZE MATCHES "undefined") # Entscheidend: ohne das MELDET UBSan den Fehler nur und laeuft weiter, # der Test wird gruen und die Meldung verschwindet im Scrollback. # So schlaegt der Test tatsaechlich fehl. @@ -35,7 +37,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "") endif() add_compile_options(${_flags}) - add_link_options(-fsanitize=${PLAYGROUND_SANITIZE}) + add_link_options(-fsanitize=${PDA_SANITIZE}) - message(STATUS "Sanitizers: ${PLAYGROUND_SANITIZE}") + message(STATUS "Sanitizers: ${PDA_SANITIZE}") endif() diff --git a/cmake/Warnings.cmake b/cmake/Warnings.cmake index 917525f..2aa0e0c 100644 --- a/cmake/Warnings.cmake +++ b/cmake/Warnings.cmake @@ -10,9 +10,14 @@ # live in Neovim angezeigt, weil clangd sie ohnehin liefert. # --------------------------------------------------------------------------- -add_library(playground_warnings INTERFACE) +# Die Datei kann aus zwei verschiedenen Projekten eingebunden werden +# (Superprojekt und libpda im Alleinbetrieb). Ohne include_guard waere der +# zweite add_library()-Aufruf ein harter Fehler: "target already exists". +include_guard(GLOBAL) -set(PLAYGROUND_GCC_LIKE_WARNINGS +add_library(pda_warnings INTERFACE) + +set(PDA_GCC_LIKE_WARNINGS -Wall -Wextra -Wpedantic @@ -44,7 +49,7 @@ set(PLAYGROUND_GCC_LIKE_WARNINGS # rohen Puffern experimentiert, sichert die Ausrichtung im Code zu # (alignas(...)), wo sie ueberpruefbar ist. -set(PLAYGROUND_C_ONLY_WARNINGS +set(PDA_C_ONLY_WARNINGS # Ein () statt (void) in C ist eine Funktion mit unbekannten Parametern -- # in C23 zwar geheilt, aber die Warnung haelt aelteren Code ehrlich. -Wstrict-prototypes @@ -52,13 +57,13 @@ set(PLAYGROUND_C_ONLY_WARNINGS -Wold-style-definition ) -set(PLAYGROUND_CXX_ONLY_WARNINGS +set(PDA_CXX_ONLY_WARNINGS -Wnon-virtual-dtor -Woverloaded-virtual -Wold-style-cast ) -target_compile_options(playground_warnings INTERFACE - $<$:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_C_ONLY_WARNINGS}> - $<$:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_CXX_ONLY_WARNINGS}> +target_compile_options(pda_warnings INTERFACE + $<$:${PDA_GCC_LIKE_WARNINGS};${PDA_C_ONLY_WARNINGS}> + $<$:${PDA_GCC_LIKE_WARNINGS};${PDA_CXX_ONLY_WARNINGS}> ) diff --git a/cmake/pdaConfig.cmake.in b/cmake/pdaConfig.cmake.in new file mode 100644 index 0000000..2c4b33d --- /dev/null +++ b/cmake/pdaConfig.cmake.in @@ -0,0 +1,28 @@ +# --------------------------------------------------------------------------- +# Vorlage fuer pdaConfig.cmake -- die Datei, die find_package(pda) sucht. +# +# Sie wird von configure_package_config_file() in libpda/CMakeLists.txt +# verarbeitet. Das @-Zeichen-Paar unten wird dabei durch CMake-Code ersetzt, +# der die Pfade relativ zum tatsaechlichen Installationsort aufloest. +# +# Ein von Hand geschriebenes Config-File mit absoluten Pfaden funktioniert nur +# auf dem Rechner, auf dem installiert wurde. +# --------------------------------------------------------------------------- +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +# Haette libpda externe Abhaengigkeiten, stuenden sie HIER: +# +# find_dependency(ZLIB REQUIRED) +# +# Das ist Pflicht, nicht Kuer: pdaTargets.cmake verweist auf ZLIB::ZLIB, und +# dieses Target existiert im Projekt des Benutzers nur, wenn es vorher +# gefunden wurde. Ohne find_dependency scheitert find_package(pda) mit +# "target ZLIB::ZLIB not found" -- einer der unverstaendlichsten CMake-Fehler. +# +# libpda hat aktuell keine, deshalb steht hier nichts. + +include("${CMAKE_CURRENT_LIST_DIR}/pdaTargets.cmake") + +check_required_components(pda) diff --git a/docs/CMAKE.md b/docs/CMAKE.md new file mode 100644 index 0000000..7a45a19 --- /dev/null +++ b/docs/CMAKE.md @@ -0,0 +1,507 @@ +# CMake verstehen + +Diese Datei erklärt CMake an *diesem* Projekt. Kein Referenzhandbuch — die +Reihenfolge folgt der Frage „wann mache ich was?". + +--- + +## 1. Das Modell: es gibt nur Targets + +CMake ist keine Skriptsprache, die Compiler-Kommandos zusammenbaut. Es ist ein +**Generator**: es baut einen Graphen aus *Targets* und schreibt daraus +Ninja-Dateien. Alles, was du in einer `CMakeLists.txt` schreibst, hat genau +einen Zweck — Targets anzulegen und ihre Eigenschaften zu setzen. + +Ein Target ist: + +| Art | Befehl | in diesem Projekt | +|---|---|---| +| Bibliothek | `add_library` | `pda`, `pda_shell`, `unity` | +| Programm | `add_executable` | `pda_app`, `calculator.test` | +| INTERFACE-Target | `add_library(x INTERFACE)` | `pda_warnings`, `doctest` | + +Ein **INTERFACE-Target** hat keinen eigenen Quellcode. Es ist ein Bündel +Eigenschaften, das man weiterreicht — `pda_warnings` in `cmake/Warnings.cmake` +ist nichts als eine Liste von `-W`-Flags mit einem Namen. + +Eigenschaften vererben sich am Graphen entlang. Das ist der ganze Trick, und +es ist der Grund, warum man **niemals** `include_directories()` oder +`add_definitions()` benutzt: die wirken global auf alles, statt an einem Target +zu hängen. + +> **Faustregel:** Jeder Befehl, den du benutzt, sollte `target_` am Anfang +> haben. Wenn nicht, frag dich, warum. + +--- + +## 2. PUBLIC, PRIVATE, INTERFACE + +Das ist der Begriff, an dem die meisten hängenbleiben. Die drei Wörter +beantworten *eine* Frage: **Wer sieht diese Eigenschaft?** + +``` + ich selbst wer mich linkt + PRIVATE ja nein + INTERFACE nein ja + PUBLIC ja ja +``` + +Aus `libpda/CMakeLists.txt`: + +```cmake +target_include_directories(pda PUBLIC ...) # contact.hpp braucht der Benutzer +target_link_libraries(pda PRIVATE pda_warnings) # unsere Warnungen sind unsere Sache +target_compile_features(pda PUBLIC cxx_std_23) # steht IM Header +``` + +Die Entscheidung hängt nur davon ab, **ob die Sache im Header vorkommt**: + +- Etwas steht in einem öffentlichen Header → `PUBLIC` +- Etwas kommt nur in der `.cpp` vor → `PRIVATE` + +`target_compile_features(pda PUBLIC cxx_std_23)` ist dafür das beste Beispiel. +`contact.hpp` gibt `std::expected` zurück. Wer libpda mit C++17 benutzen will, +soll eine klare Meldung bekommen und keinen Header-Fehler dreißig Zeilen tief. + +Ein Fall aus diesem Projekt, in `pda/CMakeLists.txt`: + +```cmake +target_link_libraries(pda_shell PUBLIC pda::pda) +``` + +`PUBLIC`, obwohl `pda_shell` intern ist — weil `shell.hpp` die Zeile +`#include ` enthält. Wäre das `PRIVATE`, würde +`shell.test.cpp` mit „file not found" scheitern. + +--- + +## 3. Reihenfolge: was muss wann stehen + +CMake liest von oben nach unten. Manches wird **beim Anlegen eines Targets** +eingefroren, anderes erst am Ende ausgewertet. + +**Muss VOR dem ersten Target stehen** (sonst wirkungslos): + +```cmake +set(CMAKE_CXX_STANDARD 23) # wird ins Target kopiert +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ...) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +``` + +Deshalb steht das alles in `cmake/ProjectDefaults.cmake`, das ganz oben +eingebunden wird. Ein `set(CMAKE_CXX_STANDARD 23)` *nach* `add_library()` +ändert nichts mehr — der häufigste stumme Fehler in CMake. + +**Darf nach dem Target stehen** (arbeitet auf dem Target): + +```cmake +target_link_libraries(...) +target_include_directories(...) +set_target_properties(...) +``` + +**Muss ins oberste `CMakeLists.txt`:** + +```cmake +enable_testing() # nur hier legt es die CTestTestfile.cmake an, die ctest sucht +``` + +--- + +## 4. Generator-Ausdrücke + +`$<...>` wird **nicht** beim Einlesen ausgewertet, sondern erst beim +Generieren — wenn CMake schon weiß, welche Konfiguration gebaut wird und ob +gerade installiert wird. Deshalb kann man damit Dinge sagen, die zur Lesezeit +noch nicht feststehen. + +Die zwei wichtigsten stehen in `libpda/CMakeLists.txt`: + +```cmake +target_include_directories(pda PUBLIC + "$" + "$") +``` + +- **`BUILD_INTERFACE`** gilt, solange im Quellbaum gebaut wird. Include-Root + ist `/libpda`, damit `` auf + `/libpda/libpda/contact.hpp` zeigt. +- **`INSTALL_INTERFACE`** gilt nach dem Installieren, relativ zum Präfix. + +**Ohne diese Trennung zeigt die installierte Bibliothek auf dein +Build-Verzeichnis.** Beim Benutzer existiert das nicht. Das ist *der* +klassische Fehler beim Verteilen einer Bibliothek, und er fällt im eigenen +Build nie auf — `tools/check-install.sh` existiert genau deswegen. + +Der zweite Fall ist subtiler: + +```cmake +target_link_libraries(pda PRIVATE "$") +``` + +Warum nicht einfach `PRIVATE pda_warnings`? Eine **statische** Bibliothek +linkt ihre Abhängigkeiten nicht selbst — das muss der tun, der sie benutzt. +CMake trägt `PRIVATE`-Abhängigkeiten deshalb trotzdem als `$` +ins Interface ein. `install(EXPORT)` sieht dort `pda_warnings`, findet es in +keinem Export-Set und bricht ab: + +``` +install(EXPORT "pdaTargets" ...) includes target "pda" which requires +target "pda_warnings" that is not in any export set. +``` + +`$` löst im Build zu `pda_warnings` auf und beim +Installieren zu nichts. + +--- + +## 5. Superprojekt und Teilprojekte + +``` +playground/ <- Superprojekt, kein eigener Quellcode +├── libpda/ <- eigenes project() +└── pda/ <- eigenes project() +``` + +P1204R0 verlangt die Trennung von Bibliothek und Programm. Der praktische +Grund: die Bibliothek muss **ohne** die Anwendung baubar sein, sonst ist sie +keine Bibliothek. + +Jedes Teilprojekt hat deshalb diesen Block: + +```cmake +if(PROJECT_IS_TOP_LEVEL) + # Alles holen, was sonst das Superprojekt bereitstellt +endif() +``` + +`PROJECT_IS_TOP_LEVEL` ist `TRUE`, wenn dieses `project()` das oberste ist. +Beide Wege müssen funktionieren: + +```sh +cmake --preset debug # Superprojekt: baut beides +cmake -S libpda -B build/nur-lib # nur die Bibliothek +cmake -S pda -B build/nur-app \ # nur die Anwendung, gegen installierte libpda + -DCMAKE_PREFIX_PATH=/pfad/zur/installation +``` + +Der dritte Fall ist der wertvollste: `pda/CMakeLists.txt` benutzt dann +`find_package(pda REQUIRED)` — also **genau den Weg, den ein fremdes Projekt +geht**. Damit ist die Anwendung gleichzeitig der Test für den Export. + +Weil `cmake/` und `third_party/` im Wurzelverzeichnis liegen, brauchen die +Teilprojekte im Alleinbetrieb die zweiargumentige Form: + +```cmake +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../third_party" third_party) +``` + +Das zweite Argument ist das *Build*-Verzeichnis. Ohne es bricht CMake ab, weil +das Quellverzeichnis außerhalb des Projekts liegt und CMake nicht raten will, +wohin die Artefakte sollen. + +Damit `cmake/Warnings.cmake` aus beiden Richtungen eingebunden werden kann, +steht dort `include_guard(GLOBAL)`. Ohne das wäre der zweite +`add_library(pda_warnings INTERFACE)` ein harter Fehler. + +--- + +## 6. Targetname ≠ Dateiname + +```cmake +add_library(pda ...) # -> libpda.a (CMake setzt "lib" davor) +add_executable(pda_app pda/main.cpp) +set_target_properties(pda_app PROPERTIES OUTPUT_NAME pda) # -> bin/pda +``` + +Zwei Targets dürfen nicht gleich heißen — die Bibliothek belegt schon `pda`. +Der *Dateiname* darf trotzdem `pda` sein. Ein Target namens `libpda` ergäbe +übrigens `liblibpda.a`. + +Dazu der Alias: + +```cmake +add_library(pda::pda ALIAS pda) +``` + +Er kostet nichts und hat einen konkreten Nutzen: ein Tippfehler in einem Namen +**mit** Doppelpunkt ist ein sofortiger CMake-Fehler. Ohne Doppelpunkt hält +CMake ihn für eine Systembibliothek und scheitert erst beim Linken, mit einer +viel schlechteren Meldung. + +--- + +## 7. Eine Bibliothek zum Verteilen: install und export + +Vier Schritte, alle in `libpda/CMakeLists.txt`: + +```cmake +# 1. Dateien kopieren UND das Target für den Export vormerken +install(TARGETS pda EXPORT pdaTargets + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +# 2. Header kopieren -- FILES_MATCHING, sonst kommen die .cpp mit +install(DIRECTORY libpda/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/libpda" + FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp") + +# 3. pdaTargets.cmake erzeugen -- die Datei, die das Target beschreibt +install(EXPORT pdaTargets FILE pdaTargets.cmake NAMESPACE pda:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda") + +# 4. pdaConfig.cmake erzeugen -- das, was find_package(pda) sucht +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/pdaConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/pdaConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda") +``` + +`find_package(pda)` sucht `pdaConfig.cmake`, das lädt `pdaTargets.cmake`, und +darin steht `pda::pda` mit allen Include-Pfaden und Flags. Der Benutzer +schreibt nur noch: + +```cmake +find_package(pda 0.1 REQUIRED) +target_link_libraries(consumer PRIVATE pda::pda) +``` + +Kein `-I`, kein `-lpda`, kein Pfad von Hand. Siehe `examples/consumer/`. + +Zwei Details, die man leicht übersieht: + +- **`GNUInstallDirs`** liefert `CMAKE_INSTALL_LIBDIR`. Auf Fedora ist das + `lib64`, nicht `lib`. Nie selbst hinschreiben. +- **`configure_package_config_file`** statt `configure_file`: es definiert + `@PACKAGE_INIT@`, das die Pfade relativ zum tatsächlichen Ort auflöst. Ein + von Hand geschriebenes Config-File mit absoluten Pfaden funktioniert nur auf + dem Rechner, auf dem installiert wurde. + +Hätte libpda externe Abhängigkeiten, müssten sie in `pdaConfig.cmake.in` +stehen: + +```cmake +find_dependency(ZLIB REQUIRED) +``` + +Das ist Pflicht: `pdaTargets.cmake` verweist auf `ZLIB::ZLIB`, und dieses +Target existiert beim Benutzer nur, wenn es vorher gefunden wurde. + +--- + +## 8. Symbolsichtbarkeit + +`ProjectDefaults.cmake` setzt: + +```cmake +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +``` + +Damit ist in einer Shared Library **kein** Symbol exportiert, solange es nicht +ausdrücklich markiert ist. Das ist unter Windows ohnehin das Verhalten — es +auch unter Unix zu erzwingen, findet Portabilitätsfehler sofort. + +Markiert wird mit einem generierten Makro: + +```cmake +include(GenerateExportHeader) +generate_export_header(pda BASE_NAME PDA + EXPORT_FILE_NAME "${CMAKE_CURRENT_BINARY_DIR}/generated/libpda/pda_export.h") +``` + +und im Header: + +```cpp +#include + +class PDA_EXPORT ContactBook { ... }; +[[nodiscard]] PDA_EXPORT std::string format_error(const EvalError&); +``` + +Dazu gehört zwingend: + +```cmake +if(NOT BUILD_SHARED_LIBS) + target_compile_definitions(pda PUBLIC PDA_STATIC_DEFINE) +endif() +``` + +Ohne das löst `PDA_EXPORT` im statischen Bau unter Windows zu +`__declspec(dllimport)` auf und der Linker sucht eine DLL, die es nicht gibt. +`PUBLIC`, weil der Benutzer denselben Header inkludiert. + +**Der Nutzen ist prüfbar:** + +```sh +cmake -S . -B build/shared -DBUILD_SHARED_LIBS=ON && cmake --build build/shared +nm -gU build/shared/lib/libpda.0.1.0.dylib | c++filt | grep -c 'pda::' # 39 +nm -gU build/shared/lib/libpda.0.1.0.dylib | c++filt | grep -c 'Parser' # 0 +``` + +Die interne `Parser`-Klasse aus `calculator.cpp` steht im anonymen Namespace +und ist nicht exportiert — sie gehört niemandem außer der Übersetzungseinheit. + +Dazu noch: + +```cmake +set_target_properties(pda PROPERTIES + VERSION 0.1.0 # libpda.dylib.0.1.0 + SOVERSION 0) # libpda.dylib.0 <- der ABI-Stand +``` + +`SOVERSION` ist die Zusicherung an den Linker: alles mit derselben Zahl ist +binärkompatibel. Sie wird erhöht, wenn sich das ABI ändert — nicht bei jedem +Release. + +--- + +## 9. Tests + +```cmake +enable_testing() # nur im obersten CMakeLists.txt +add_test(NAME "unity.textfile" COMMAND textfile.test) +``` + +Für C++ läuft das nicht von Hand, sondern über doctest: + +```cmake +doctest_discover_tests(calculator.test TEST_PREFIX "doctest.") +``` + +Das ruft die Test-Executable **beim Build** mit `--list-test-cases` auf und +legt einen CTest-Eintrag pro `TEST_CASE` an. Ohne das sähe CTest nur einen +groben Eintrag pro Datei, und neotest könnte Testnamen nicht auf Quellzeilen +abbilden. + +> Nach einem frischen Clone ist der Testbaum deshalb leer, bis einmal gebaut +> wurde. Das ist kein Fehler. + +Die Anwendung braucht einen Kniff, der weit über CMake hinaus gilt: + +```cmake +add_library(pda_shell STATIC pda/shell.cpp) # alles Testbare +add_executable(pda_app pda/main.cpp) # nur Ein-/Ausgabe +target_link_libraries(pda_app PRIVATE pda_shell) +``` + +Ein Unit-Test kann `pda_shell` linken. Würde die Logik direkt in `pda_app` +stecken, müsste der Test `shell.cpp` erneut übersetzen **und** würde +`main.cpp` mitziehen — zwei `main()` in einer Executable sind ein +Linkerfehler. + +--- + +## 10. Presets + +`CMakePresets.json` ersetzt handgeschriebene Kommandozeilen: + +```sh +cmake --preset debug # konfigurieren +cmake --build --preset debug # bauen +ctest --preset debug # testen +``` + +| Preset | Zweck | +|---|---| +| `debug` | Alltag | +| `release` | RelWithDebInfo | +| `asan-ubsan` | AddressSanitizer + UBSan | +| `apple-clang` | Portabilitätsprobe, macOS-Systemcompiler | +| `homebrew-gcc` | Portabilitätsprobe, GCC-Frontend | +| `linux-gcc` | nur auf Linux sichtbar (`condition`) | + +`binaryDir` ist `build/` und die **Tiefe ist bindend**: der +neotest-CTest-Adapter sucht nur drei Ebenen tief, und der Neovim-Target-Picker +leitet den File-API-Pfad daraus ab. + +Optionen setzt man ohne neuen Preset dazu: + +```sh +cmake --preset debug -DBUILD_SHARED_LIBS=ON -DPDA_BUILD_TESTS=OFF +``` + +`BUILD_SHARED_LIBS` ist ein CMake-Konventionsname — `add_library()` ohne +`STATIC`/`SHARED` richtet sich danach. Deshalb nie selbst erfinden. + +--- + +## 11. Wann mache ich was? + +**Eine neue Datei zu einem bestehenden Modul** + +Nichts. Header werden über `#include` gefunden. + +**Ein neues Modul in der Bibliothek** + +1. `libpda/libpda/.hpp` + `.cpp` anlegen +2. Beide in `LIBPDA_SOURCES` / `LIBPDA_HEADERS` eintragen — **kein `file(GLOB)`** +3. Öffentliche Deklarationen mit `PDA_EXPORT` markieren +4. `libpda/libpda/.test.cpp` daneben, `libpda_add_doctest_test()` + +**Warum kein `file(GLOB)`:** CMake wertet ihn beim *Konfigurieren* aus. Kommt +später eine Datei dazu, merkt der Build es nicht — er bleibt grün, und die +Datei ist einfach nicht dabei. `CONFIGURE_DEPENDS` mildert das, kostet aber bei +jedem Build einen Verzeichnis-Scan und ist laut CMake-Doku nicht zuverlässig. + +**Ein neues Kommando in der Anwendung** + +Nur `pda/pda/shell.cpp` ändern. `shell.cpp` steht schon in `pda_shell`. + +**Eine externe Abhängigkeit** + +```cmake +find_package(fmt REQUIRED) +target_link_libraries(pda PRIVATE fmt::fmt) +``` + +Steht sie in einem **öffentlichen Header**, muss sie `PUBLIC` sein **und** in +`cmake/pdaConfig.cmake.in` als `find_dependency(fmt REQUIRED)` auftauchen. +Sonst scheitert `find_package(pda)` beim Benutzer mit „target fmt::fmt not +found". + +**Etwas soll nur im Debug-Bau passieren** + +```cmake +target_compile_definitions(pda PRIVATE "$<$:PDA_DEBUG>") +``` + +Nicht `if(CMAKE_BUILD_TYPE STREQUAL Debug)` — das bricht bei +Multi-Config-Generatoren (Xcode, Visual Studio), wo die Konfiguration erst +beim Bauen feststeht. + +--- + +## 12. Fehler, die dieses Projekt schon eingebaut hat + +| Symptom | Ursache | Wo nachlesen | +|---|---|---| +| `requires target "pda_warnings" that is not in any export set` | `PRIVATE` reicht bei statischen Bibliotheken nicht | Abschnitt 4 | +| Shared-Bau linkt nicht, alle Symbole fehlen | `hidden` gesetzt, aber `PDA_EXPORT` nirgends benutzt | Abschnitt 8 | +| `set(CMAKE_CXX_STANDARD)` wirkt nicht | steht nach `add_library()` | Abschnitt 3 | +| Benutzer bekommt Pfad ins Build-Verzeichnis | `BUILD_INTERFACE`/`INSTALL_INTERFACE` fehlt | Abschnitt 4 | +| Neue Datei wird nicht gebaut | `file(GLOB)` | Abschnitt 11 | + +Die ersten beiden sind beim Bau *dieses* Projekts tatsächlich aufgetreten. + +--- + +## 13. Werkzeuge zum Nachsehen + +```sh +cmake --build build/debug --target help # alle Targets +cmake -S . -B build/debug --graphviz=g.dot # Abhängigkeitsgraph +cmake --install build/debug --prefix /tmp/p # ohne echte Installation ausprobieren +./tools/check-install.sh # Export-Kette Ende-zu-Ende +``` + +Um zu sehen, was ein Target tatsächlich erbt: + +```cmake +get_target_property(dirs pda INTERFACE_INCLUDE_DIRECTORIES) +message(STATUS "pda erbt: ${dirs}") +``` + +Und für die echten Compiler-Kommandos: `build/debug/compile_commands.json` — +dieselbe Datei, die clangd liest. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 3f97ca3..79b23a6 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -8,39 +8,59 @@ ebenfalls nicht) — die folgenden Regeln gelten also per Konvention. ## Projektstruktur (P1204R0) -``` -playground/ -├── playground/ <- Quellcode UND Header zusammen -│ ├── counter.h public API (C) -│ ├── counter.c -│ ├── counter.test.c Unit-Test, direkt neben dem Modul -│ ├── notebook.hpp public API (C++) -│ ├── notebook.cpp -│ ├── notebook.test.cpp -│ ├── main.cpp -│ └── details/ Implementation Details, nicht public API -├── tests/basics/ Integrationstests gegen die öffentliche API -├── third_party/ vendorter Fremdcode, nie editieren -├── cmake/ Warnings.cmake, Sanitizers.cmake -└── docs/ diese Datei, ADRs -``` +Zwei eigenständige Projekte unter einem Superprojekt: -`counter` und `notebook` sind Gerüst: sie zeigen jede Regel einmal an einem -lauffähigen Beispiel und sind dazu da, ersetzt zu werden. Was bleiben soll, -ist die Form, nicht der Inhalt. +``` +playground/ Superprojekt, kein eigener Quellcode +├── libpda/ project(libpda) -> libpda.a +│ ├── libpda/ Quellcode UND Header zusammen +│ │ ├── textfile.h public API (C) +│ │ ├── textfile.c +│ │ ├── textfile.test.c Unit-Test, direkt neben dem Modul +│ │ ├── contact.hpp public API (C++) +│ │ ├── contact.cpp +│ │ ├── contact.test.cpp +│ │ └── details/ Implementation Details, nicht public API +│ └── tests/basics/ Integrationstests gegen die öffentliche API +├── pda/ project(pda) -> bin/pda +│ ├── pda/ shell.hpp/.cpp, main.cpp +│ └── tests/session/ +├── examples/consumer/ fremdes Projekt, find_package(pda) +├── third_party/ vendorter Fremdcode, nie editieren +├── cmake/ ProjectDefaults, Warnings, Sanitizers, pdaConfig.cmake.in +└── docs/ CMAKE.md, diese Datei, ADRs +``` **Kein `include/` + `src/`.** Header und Implementierung liegen nebeneinander. -Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber -zu ziehen, und der Verzeichnisname `playground/` übernimmt die Rolle des Namespace -im Dateisystem. +Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber zu +ziehen, und der Verzeichnisname übernimmt die Rolle des Namespace im +Dateisystem. + +**Bibliothek und Programm sind getrennte Projekte.** P1204R0 verlangt das; der +praktische Grund ist, dass `libpda` ohne `pda` baubar sein muss. Die Probe: + +```sh +cmake -S libpda -B build/nur-lib && cmake --build build/nur-lib +``` + +**Ein Namespace, zwei Include-Wurzeln.** Beide Projekte benutzen +`namespace pda` — auseinandergehalten wird über den Pfad: + +```cpp +#include // Bibliothek +#include // Anwendung +``` + +Das ist genau das Schema aus P1204R0 (`libhello` / `hello`): das `lib`-Präfix +steht im Projekt- und Verzeichnisnamen, nicht im Namespace. ## Includes **Eigene Header immer mit spitzen Klammern und Projektpräfix:** ```c -#include /* richtig */ -#include "counter.h" /* falsch */ +#include /* richtig */ +#include "textfile.h" /* falsch */ ``` Das ist P1204R0s wichtigste Einzelregel. Spitze Klammern durchsuchen nur die @@ -68,13 +88,13 @@ ein `.hpp` nur aus C++. | Was | Form | Beispiel | |---|---|---| -| Typen | `PascalCase` | `Counter`, `Notebook` | -| Funktionen, Variablen | `snake_case` | `counter_tick`, `free_space_offset` | -| Makros | `UPPER_CASE` mit Projektpräfix | `PLAYGROUND_COUNTER_LIMIT` | -| C-API | Modulpräfix | `counter_create`, `counter_free`, `counter_tick` | +| Typen | `PascalCase` | `ContactBook`, `TextBuffer`, `Explorer` | +| Funktionen, Variablen | `snake_case` | `human_size`, `line_count` | +| Makros | `UPPER_CASE` mit Projektpräfix | `LIBPDA_TEXTFILE_H`, `PDA_EXPORT` | +| C-API | Modulpräfix | `textfile_read`, `textfile_write`, `textfile_escape` | | Out-Parameter | `out_`-Präfix | `size_t* out_len` | -| Member | blank, kein `m_`, kein `_` | `items`, `width` | -| Namespace | Projektname ohne `lib` | `namespace playground` | +| Member | blank, kein `m_`, kein `_` | `entries`, `rows`, `here` | +| Namespace | Projektname ohne `lib` | `namespace pda` | ## Kommentare @@ -95,11 +115,11 @@ grep -rlP '[^\x00-\x7F]' playground tests ## Header -`#ifndef PLAYGROUND_COUNTER_H` — Include-Guards, kein `#pragma once`. +`#ifndef LIBPDA_TEXTFILE_H` — Include-Guards, kein `#pragma once`. Öffentliche C-Header bekommen einen `extern "C"`-Block, damit die C++-Seite sie benutzen kann. -Structs, deren Layout niemanden angeht, sind opak: `typedef struct Counter Counter;` +Structs, deren Layout niemanden angeht, sind opak: `typedef struct Page Page;` im Header, die Definition in der `.c`. ## Tests @@ -107,7 +127,7 @@ im Header, die Definition in der `.c`. **Unit-Test** (`.test.c` / `.test.cpp`) liegt neben dem Modul, kennt dessen Interna und ist eine eigenständige Executable. -- C → Unity, registriert als CTest `unity.` (hier: `unity.counter`) +- C → Unity, registriert als CTest `unity.` (hier: `unity.textfile`) - C++ → doctest, `doctest_discover_tests()` legt **einen CTest-Eintrag pro `TEST_CASE`** an @@ -120,6 +140,12 @@ Implementierung) → REFACTOR. ## Build +Die ausführliche Erklärung steht in [CMAKE.md](CMAKE.md). Das Wichtigste: + +- Öffentliche Deklarationen der Bibliothek brauchen `PDA_EXPORT`, sonst + exportiert der Shared-Bau sie nicht. +- Neue Dateien immer von Hand in `LIBPDA_SOURCES` / `LIBPDA_HEADERS`. + Kein `file(GLOB)`. Neue Dateien werden in `CMakeLists.txt` eingetragen — CMake merkt sonst nicht, dass eine Datei dazugekommen ist, der Build bleibt grün und die Datei fehlt einfach. diff --git a/docs/adr/0001-toolchain.md b/docs/adr/0001-toolchain.md index 9e751b9..9facd04 100644 --- a/docs/adr/0001-toolchain.md +++ b/docs/adr/0001-toolchain.md @@ -12,10 +12,15 @@ bereits beantwortet. ## Entscheidung -Struktur nach P1204R0, Build- und Stilkonfiguration wörtlich von `mydb` -übernommen (`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`, -`CMakePresets.json`, `cmake/`), lediglich das Makro- und Target-Präfix von -`MYDB_`/`mydb_` auf `PLAYGROUND_`/`playground_` umgestellt. +Struktur nach P1204R0, Build- und Stilkonfiguration von `mydb` übernommen +(`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`, +`CMakePresets.json`, `cmake/`), Makro- und Target-Präfix auf `PDA_`/`pda_` +umgestellt. + +Zwei Abweichungen vom Hausstandard, beide in [0002](0002-pda-aufteilung.md) +begründet: die Aufteilung in zwei Projekte und das daraus folgende +`cmake/ProjectDefaults.cmake`, das die Grundeinstellungen aus dem +`CMakeLists.txt` herauszieht, damit beide Teilprojekte sie teilen können. Die vendorten Abhängigkeiten (Unity 2.7.0, doctest 2.5.3) wurden aus `mydb/third_party/` kopiert statt neu geholt — damit sind die Versionen über @@ -32,4 +37,5 @@ ist der Ort, an dem eine gemeinsame Vorlage entstehen müsste. Offen bleibt, dass `docs/CONVENTIONS.md` in `mydb` ein `check_style.sh` und ein `tools/vendor.sh` beschreibt, die in keinem der Repos existieren. In der -playground-Fassung sind diese Verweise entfernt. +playground-Fassung sind diese Verweise entfernt; `tools/` enthält hier +stattdessen `check-install.sh`, das tatsächlich existiert und läuft. diff --git a/docs/adr/0002-pda-aufteilung.md b/docs/adr/0002-pda-aufteilung.md new file mode 100644 index 0000000..8b3cc9e --- /dev/null +++ b/docs/adr/0002-pda-aufteilung.md @@ -0,0 +1,67 @@ +# ADR 0002 — Aufteilung in libpda und pda + +- Status: akzeptiert +- Datum: 2026-08-30 + +## Kontext + +Das Projekt soll einen kleinen PDA umsetzen (Kontakte, Rechner, Notizen, +Dateiexplorer) und dabei ausdrücklich als **Bibliothek** benutzbar sein — man +soll damit eigene Sachen bauen können. + +P1204R0 sagt dazu: „If a project consists of a library and an executable, then +they should be split into separate projects." + +Die naheliegende Alternative wäre ein Projekt mit einem `pda_core`-Target und +einer Executable daneben — so machen es `mydb` und `cxx_scaffold_cli`. + +## Entscheidung + +Zwei eigenständige Projekte unter einem Superprojekt: + +- `libpda/` mit eigenem `project(libpda)`, exportiert `pda::pda` +- `pda/` mit eigenem `project(pda)`, linkt `pda::pda` + +Beide sind einzeln konfigurierbar. `pda/CMakeLists.txt` benutzt im +Alleinbetrieb `find_package(pda REQUIRED)`. + +## Konsequenzen + +Die Bibliothek ist nachweislich unabhängig: `cmake -S libpda -B build/nur-lib` +baut und testet sie ohne die Anwendung (47 der 71 Tests). + +Die Anwendung ist damit gleichzeitig der Integrationstest für den +Export-Mechanismus. Was sie im Alleinbetrieb tut, tut auch jedes fremde +Projekt. + +`examples/consumer/` und `tools/check-install.sh` machen das explizit: das +Skript installiert `libpda` in ein temporäres Präfix und baut ein fremdes +Programm dagegen. Genau diese Kette fängt den häufigsten Verteilungsfehler — +ein `target_include_directories` ohne `BUILD_INTERFACE`/`INSTALL_INTERFACE` +zeigt beim Benutzer ins Leere und fällt im eigenen Build nie auf. + +Der Preis ist eine Ebene mehr im Pfad (`libpda/libpda/contact.hpp`) und der +`PROJECT_IS_TOP_LEVEL`-Block in beiden Teilprojekten. Beides ist der Preis +dafür, dass „Bibliothek" hier nicht nur ein Wort ist. + +## Nebenentscheidungen + +**Ein Namespace für beide Projekte** (`namespace pda`), unterschieden über den +Include-Pfad — so wie `libhello`/`hello` in P1204R0. Das `lib`-Präfix steht im +Projekt- und Verzeichnisnamen, nicht im Namespace. + +**Die C-Schicht ist echt, nicht dekorativ.** `textfile.c` macht Datei-I/O und +Feld-Escaping — die Stelle, an der Besitzverhältnisse und rohe Puffer +sichtbar sind. Alles darüber ist C++ und verpackt das in RAII. Die Naht ist in +`contact.cpp` zu sehen: `unique_ptr` mit eigenem Deleter um die `char*`, die +`textfile_escape` liefert. + +**`std::expected` statt Exceptions** für Benutzerfehler (Tippfehler im +Ausdruck, fehlende Datei). Ein Parser-Fehler ist der Normalfall dieser +Funktionen, kein Ausnahmezustand. Exceptions bleiben für das, was wirklich +nicht vorgesehen ist. + +**Die Shell kennt keine Ein-/Ausgabe.** `Shell::execute()` bekommt eine Zeile +und gibt Text zurück; wer ihn anzeigt, ist ihre Sache nicht. Deshalb braucht +kein einziger der Shell-Tests ein Terminal, und eine GUI ließe sich ohne +Änderung an `shell.cpp` davorsetzen. diff --git a/examples/consumer/CMakeLists.txt b/examples/consumer/CMakeLists.txt new file mode 100644 index 0000000..23def10 --- /dev/null +++ b/examples/consumer/CMakeLists.txt @@ -0,0 +1,31 @@ +# --------------------------------------------------------------------------- +# Beispiel: ein FREMDES Projekt benutzt libpda. +# +# Dieses Verzeichnis ist bewusst NICHT Teil des Superprojekts -- es wird +# nirgends per add_subdirectory eingebunden. Es wird eigenstaendig +# konfiguriert, gegen eine INSTALLIERTE libpda: +# +# cmake -S examples/consumer -B build/consumer \ +# -DCMAKE_PREFIX_PATH=/pfad/zur/installation +# +# Genau das macht tools/check-install.sh automatisch. Wenn das hier baut, ist +# der Export in libpda/CMakeLists.txt korrekt -- und nur dann. +# --------------------------------------------------------------------------- +cmake_minimum_required(VERSION 3.28) + +project(pda_consumer VERSION 1.0.0 LANGUAGES CXX) + +# find_package sucht pdaConfig.cmake in CMAKE_PREFIX_PATH unter +# lib/cmake/pda/. Findet es die Datei nicht, bricht REQUIRED sofort ab -- +# mit einer Meldung, die sagt, wo gesucht wurde. +find_package(pda 0.1 REQUIRED) + +add_executable(consumer main.cpp) + +# pda::pda -- der Name aus dem NAMESPACE-Argument von install(EXPORT). +# Include-Pfade, der C++23-Standard und die Bibliotheksdatei kommen alle +# ueber dieses eine Target. Kein target_include_directories noetig, kein +# -lpda, kein Pfad von Hand. +target_link_libraries(consumer PRIVATE pda::pda) + +message(STATUS "consumer: libpda ${pda_VERSION} gefunden") diff --git a/examples/consumer/main.cpp b/examples/consumer/main.cpp new file mode 100644 index 0000000..17d693d --- /dev/null +++ b/examples/consumer/main.cpp @@ -0,0 +1,39 @@ +/* examples/consumer/main.cpp + * Ein fremdes Programm, das libpda benutzt. + * + * Es kennt das Projekt nicht -- nur das installierte Paket. Die Includes + * sehen deshalb genauso aus wie bei jedem anderen Benutzer, und es gibt keine + * relativen Pfade irgendwohin. + */ +#include +#include + +#include +#include +#include + +int +main() +{ + /* Rechner */ + const auto value = pda::evaluate("(1 + 2) * 3 ^ 2"); + if (!value) + { + std::fprintf(stderr, "consumer: %s\n", pda::format_error(value.error()).c_str()); + return 1; + } + + std::printf("(1 + 2) * 3 ^ 2 = %g\n", *value); + + /* Kontakte */ + pda::ContactBook book; + book.add(pda::Contact{"Anna", "0151", "anna@example.org", "Test"}); + std::printf("Kontakte: %zu\n", book.size()); + + /* Notizen */ + pda::TextBuffer notes; + notes.append("libpda laeuft aus einem fremden Projekt"); + std::printf("Notiz: %s\n", std::string{*notes.line(1U)}.c_str()); + + return *value == 27.0 ? 0 : 1; +} diff --git a/libpda/CMakeLists.txt b/libpda/CMakeLists.txt new file mode 100644 index 0000000..202a84d --- /dev/null +++ b/libpda/CMakeLists.txt @@ -0,0 +1,276 @@ +# --------------------------------------------------------------------------- +# libpda - die Bibliothek +# +# Eigenstaendiges Projekt (P1204R0). Laeuft in zwei Betriebsarten: +# +# 1. als Teil des Superprojekts: cmake --preset debug +# 2. allein: cmake -S libpda -B build/nur-lib +# +# Betriebsart 2 ist der Grund fuer den PROJECT_IS_TOP_LEVEL-Block unten: was +# sonst das Superprojekt bereitstellt, muss die Bibliothek sich dann selbst +# holen. +# --------------------------------------------------------------------------- +cmake_minimum_required(VERSION 3.28) + +project(libpda + VERSION 0.1.0 + DESCRIPTION "PDA-Kernbibliothek: Kontakte, Rechner, Editor, Explorer" + LANGUAGES C CXX) + +# PROJECT_IS_TOP_LEVEL (CMake >= 3.21) ist TRUE, wenn dieses project() das +# oberste ist -- also genau in Betriebsart 2. +if(PROJECT_IS_TOP_LEVEL) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../cmake") + + include(ProjectDefaults) + include(Warnings) + include(Sanitizers) + + option(BUILD_SHARED_LIBS "Bibliothek als Shared Library bauen" OFF) + option(PDA_BUILD_TESTS "Tests bauen" ON) + + if(PDA_BUILD_TESTS) + enable_testing() + + # Die zweiargumentige Form von add_subdirectory: das Quellverzeichnis + # liegt AUSSERHALB dieses Projekts, also muss man CMake sagen, wohin + # die Build-Artefakte sollen. Ohne das zweite Argument bricht CMake ab. + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../third_party" third_party) + endif() +endif() + +# --------------------------------------------------------------------------- +# Quellen. Kein file(GLOB): CMake merkt nicht, wenn eine Datei dazukommt -- +# der Build bleibt gruen und die neue Datei ist einfach nicht dabei. +# +# *.test.c und *.test.cpp gehoeren NICHT hierher (P1204R0 Regel 7.1); die +# werden weiter unten zu eigenen Executables. +# --------------------------------------------------------------------------- +set(LIBPDA_SOURCES + libpda/textfile.c + libpda/calculator.cpp + libpda/contact.cpp + libpda/editor.cpp + libpda/explorer.cpp +) + +# Header hier aufzuzaehlen ist fuer den Compiler ueberfluessig -- er findet sie +# ueber die #includes. Es hat zwei andere Zwecke: IDEs zeigen sie im +# Projektbaum, und ein Blick in diese Liste sagt, was die Bibliothek ausmacht. +set(LIBPDA_HEADERS + libpda/textfile.h + libpda/calculator.hpp + libpda/contact.hpp + libpda/editor.hpp + libpda/explorer.hpp + libpda/details/version.hpp +) + +# Targetname "pda", nicht "libpda": CMake stellt auf Unix selbst ein "lib" +# voran, das Ergebnis heisst also libpda.a. Ein Target namens "libpda" ergaebe +# liblibpda.a. +add_library(pda ${LIBPDA_SOURCES} ${LIBPDA_HEADERS}) + +# Der Alias ist das, was Benutzer schreiben: target_link_libraries(x PRIVATE +# pda::pda). Er kostet nichts und hat einen konkreten Nutzen -- ein Tippfehler +# in einem Namen MIT Doppelpunkt ist ein sofortiger CMake-Fehler, ohne +# Doppelpunkt haelt CMake ihn fuer den Namen einer Systembibliothek und +# scheitert erst beim Linken. +add_library(pda::pda ALIAS pda) + +# --------------------------------------------------------------------------- +# Include-Pfade -- der wichtigste Block dieser Datei. +# +# PUBLIC heisst: gilt fuer diese Bibliothek UND fuer jeden, der sie linkt. +# (PRIVATE = nur hier, INTERFACE = nur fuer die anderen.) +# +# Die beiden Generator-Ausdruecke unterscheiden zwei Welten: +# +# BUILD_INTERFACE waehrend hier gebaut wird. Der Include-Root ist +# /libpda, damit auf +# /libpda/libpda/contact.hpp zeigt. +# +# INSTALL_INTERFACE nachdem installiert wurde. Der Pfad ist relativ zum +# Installationspraefix, also /include, und +# zeigt auf +# /include/libpda/contact.hpp. +# +# Ohne diese Trennung wuerde die installierte Bibliothek in ihrer +# CMake-Konfigurationsdatei auf DEIN Build-Verzeichnis zeigen. Beim Benutzer +# existiert das nicht -- der klassische "works on my machine"-Fehler beim +# Verteilen einer Bibliothek. +# --------------------------------------------------------------------------- +target_include_directories(pda PUBLIC + "$" + "$" +) + +# Auch das generierte Export-Header-Verzeichnis muss in beiden Welten stimmen. +target_include_directories(pda PUBLIC + "$" + "$" +) + +# Die Warnungen gelten beim Bauen DIESER Bibliothek. Wer sie linkt, soll +# unsere Warnungsliste nicht erben -- fremde Projekte haben ihre eigene. +# +# Warum $ und nicht einfach PRIVATE: +# +# Bei einer STATISCHEN Bibliothek reicht PRIVATE nicht. Eine .a-Datei linkt +# ihre Abhaengigkeiten nicht selbst -- das muss der tun, der sie benutzt. +# CMake traegt PRIVATE-Abhaengigkeiten deshalb trotzdem als $ +# ins INTERFACE_LINK_LIBRARIES ein. install(EXPORT) sieht dort pda_warnings +# stehen, findet es in keinem Export-Set und bricht ab: +# +# install(EXPORT "pdaTargets" ...) includes target "pda" which requires +# target "pda_warnings" that is not in any export set. +# +# Drei Wege heraus: pda_warnings mitexportieren (verseucht das Paket mit +# unseren Flags), die Flags direkt per target_compile_options setzen (dann +# bekommt third_party/ sie auch), oder -- richtig -- den Generator-Ausdruck +# unten: er loest im Build-Baum zu "pda_warnings" auf und beim Installieren +# zu nichts. +target_link_libraries(pda PRIVATE "$") + +# Der C++-Standard als PUBLIC-Anforderung: wer libpda benutzt, braucht +# zwingend C++23, weil contact.hpp im Interface hat. Ohne das +# scheitert das Benutzerprojekt mit einem unverstaendlichen Header-Fehler +# statt mit einer klaren Meldung. +target_compile_features(pda PUBLIC cxx_std_23) + +# --------------------------------------------------------------------------- +# Export-Header: erzeugt mit dem Makro PDA_EXPORT. +# +# Warum: bei einer Shared Library sind Symbole dank CXX_VISIBILITY_PRESET +# hidden unsichtbar, unter Windows grundsaetzlich. Das Makro loest sich je +# nach Plattform und Bauart in __declspec(dllexport), __attribute__((visibility +# ("default"))) oder nichts auf. +# +# Wir generieren die Datei, statt sie von Hand zu schreiben: CMake kennt die +# richtige Variante fuer jeden Compiler. +# --------------------------------------------------------------------------- +include(GenerateExportHeader) +generate_export_header(pda + BASE_NAME PDA + EXPORT_FILE_NAME "${CMAKE_CURRENT_BINARY_DIR}/generated/libpda/pda_export.h") + +# Ohne diese Definition loest PDA_EXPORT im statischen Bau unter Windows zu +# __declspec(dllimport) auf -- der Linker sucht dann eine DLL, die es nicht +# gibt. generate_export_header sieht dafuer PDA_STATIC_DEFINE vor. +# +# PUBLIC, nicht PRIVATE: die Definition muss auch beim BENUTZER gelten, denn +# er inkludiert denselben Header. +if(NOT BUILD_SHARED_LIBS) + target_compile_definitions(pda PUBLIC PDA_STATIC_DEFINE) +endif() + +set_target_properties(pda PROPERTIES + VERSION ${PROJECT_VERSION} # libpda.dylib.0.1.0 + SOVERSION ${PROJECT_VERSION_MAJOR} # libpda.dylib.0 <- ABI-Stand +) + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +if(PDA_BUILD_TESTS) + # Unit-Test in C: .test.c -> Executable .test -> CTest + # "unity.". Der neotest-Adapter in Neovim erwartet genau diesen Namen. + function(libpda_add_unity_test module) + if(NOT TARGET unity) + return() + endif() + set(target "${module}.test") + add_executable(${target} "libpda/${module}.test.c") + target_link_libraries(${target} PRIVATE pda unity pda_warnings) + add_test(NAME "unity.${module}" COMMAND ${target}) + endfunction() + + # Unit-Test in C++: doctest_discover_tests() ruft die Executable beim Build + # mit --list-test-cases auf und legt EINEN CTest-Eintrag pro TEST_CASE an. + # Das ist Pflicht, nicht Kosmetik: neotest-ctest kann Testnamen nur so auf + # Quellpositionen abbilden. + function(libpda_add_doctest_test module) + if(NOT TARGET doctest) + return() + endif() + set(target "${module}.test") + add_executable(${target} "libpda/${module}.test.cpp") + target_link_libraries(${target} PRIVATE pda doctest pda_warnings) + doctest_discover_tests(${target} TEST_PREFIX "doctest.") + endfunction() + + libpda_add_unity_test(textfile) + libpda_add_doctest_test(calculator) + libpda_add_doctest_test(contact) + libpda_add_doctest_test(editor) + libpda_add_doctest_test(explorer) + + add_subdirectory(tests) +endif() + +# --------------------------------------------------------------------------- +# Installation und Export +# +# Ab hier wird aus einem Build-Verzeichnis eine Bibliothek, die ANDERE +# Projekte mit find_package(pda) benutzen koennen. Vier Schritte: +# +# 1. install(TARGETS ... EXPORT ...) Dateien kopieren, Target vormerken +# 2. install(DIRECTORY ...) Header kopieren +# 3. install(EXPORT ...) pdaTargets.cmake erzeugen +# 4. configure_package_config_file() pdaConfig.cmake erzeugen +# +# find_package(pda) sucht nach pdaConfig.cmake, das laedt pdaTargets.cmake, +# und darin steht das Target pda::pda mit allen Include-Pfaden und Flags. +# --------------------------------------------------------------------------- +include(GNUInstallDirs) # liefert CMAKE_INSTALL_LIBDIR usw. -- auf Fedora + # ist das lib64, nicht lib. Nie selbst hinschreiben. +include(CMakePackageConfigHelpers) + +install(TARGETS pda + EXPORT pdaTargets + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +# Header. FILES_MATCHING mit PATTERN, weil sonst auch .cpp und .test.cpp +# mitkopiert wuerden -- install(DIRECTORY) nimmt per Default ALLES. +install(DIRECTORY libpda/ + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/libpda" + FILES_MATCHING + PATTERN "*.h" + PATTERN "*.hpp" + PATTERN "private" EXCLUDE) + +# Der generierte Export-Header liegt im Build-, nicht im Quellbaum. +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/generated/libpda/pda_export.h" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/libpda") + +# NAMESPACE pda:: sorgt dafuer, dass das importierte Target genauso heisst wie +# der Alias oben. Benutzer schreiben pda::pda -- egal ob sie die Bibliothek +# installiert haben oder per add_subdirectory einbinden. +install(EXPORT pdaTargets + FILE pdaTargets.cmake + NAMESPACE pda:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda") + +# Versionsdatei. SameMajorVersion heisst: find_package(pda 0.1) akzeptiert +# 0.9, aber nicht 1.0 -- die uebliche Semver-Zusicherung. +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/pdaConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +# configure_package_config_file statt configure_file: es definiert zusaetzlich +# PACKAGE_INIT, das die Pfade relativ zum tatsaechlichen Installationsort +# aufloest. Damit funktioniert das Paket auch, wenn es jemand nach dem +# Installieren verschiebt. +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/../cmake/pdaConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/pdaConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda") + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/pdaConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/pdaConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/pda") diff --git a/libpda/libpda/calculator.cpp b/libpda/libpda/calculator.cpp new file mode 100644 index 0000000..c3839a3 --- /dev/null +++ b/libpda/libpda/calculator.cpp @@ -0,0 +1,228 @@ +/* libpda/calculator.cpp + * Tokenizer und Recursive-Descent-Parser. + */ +#include +#include +#include +#include + +#include + +namespace pda +{ +namespace +{ + +/* Der Parser haelt die Eingabe und einen Lesezeiger. Alles im anonymen + * Namespace: diese Klasse verlaesst die Uebersetzungseinheit nicht und taucht + * damit auch nicht im Symbolexport der Bibliothek auf. */ +class Parser +{ +public: + explicit Parser(std::string_view input) noexcept : text{input} {} + + std::expected + parse() + { + auto value = expression(); + if (!value) return value; + + skip_spaces(); + if (pos < text.size()) + { + return fail(std::format("unerwartetes Zeichen '{}'", text[pos])); + } + + return value; + } + +private: + std::string_view text; + std::size_t pos{0U}; + + std::unexpected + fail(std::string message) const + { + return std::unexpected{EvalError{pos, std::move(message)}}; + } + + void + skip_spaces() + { + while (pos < text.size() && (text[pos] == ' ' || text[pos] == '\t')) + pos++; + } + + /* Schaut auf das naechste bedeutungstragende Zeichen, ohne es zu + * verbrauchen. '\0' heisst "Eingabe zu Ende". */ + char + peek() + { + skip_spaces(); + return pos < text.size() ? text[pos] : '\0'; + } + + /* Verbraucht das Zeichen c, wenn es als naechstes kommt. */ + bool + consume(char expected) + { + if (peek() != expected) return false; + + pos++; + return true; + } + + /* ---- Grammatikregeln ---- */ + + std::expected + expression() + { + auto left = term(); + if (!left) return left; + + for (;;) + { + const char op = peek(); + if (op != '+' && op != '-') return left; + + pos++; + auto right = term(); + if (!right) return right; + + *left = (op == '+') ? (*left + *right) : (*left - *right); + } + } + + std::expected + term() + { + auto left = power(); + if (!left) return left; + + for (;;) + { + const char op = peek(); + if (op != '*' && op != '/' && op != '%') return left; + + const std::size_t op_pos = pos; + pos++; + + auto right = power(); + if (!right) return right; + + if ((op == '/' || op == '%') && *right == 0.0) + { + /* Position auf den Operator, nicht auf das Ende: dort steht + * der Fehler aus Sicht des Benutzers. */ + pos = op_pos; + return fail("Division durch null"); + } + + if (op == '*') + { + *left = *left * *right; + } + else if (op == '/') + { + *left = *left / *right; + } + else + { + *left = std::fmod(*left, *right); + } + } + } + + /* Rechtsassoziativ: 2^3^2 ist 2^(3^2) = 512, nicht (2^3)^2 = 64. + * Deshalb ruft power() sich rechts SELBST auf, statt zu schleifen. */ + std::expected + power() + { + auto base = unary(); + if (!base) return base; + + if (!consume('^')) return base; + + auto exponent = power(); + if (!exponent) return exponent; + + return std::pow(*base, *exponent); + } + + std::expected + unary() + { + if (consume('-')) + { + auto value = unary(); + if (!value) return value; + + return -*value; + } + + if (consume('+')) return unary(); + + return primary(); + } + + std::expected + primary() + { + if (consume('(')) + { + auto value = expression(); + if (!value) return value; + + if (!consume(')')) return fail("schliessende Klammer fehlt"); + + return value; + } + + return number(); + } + + std::expected + number() + { + skip_spaces(); + const std::size_t start = pos; + + while (pos < text.size() && (text[pos] >= '0' && text[pos] <= '9')) + pos++; + + if (pos < text.size() && text[pos] == '.') + { + pos++; + while (pos < text.size() && (text[pos] >= '0' && text[pos] <= '9')) + pos++; + } + + if (pos == start) + { + return fail(pos < text.size() ? std::format("Zahl erwartet, '{}' gefunden", text[pos]) + : std::string{"Zahl erwartet, Eingabe zu Ende"}); + } + + /* std::stod statt from_chars, weil die Eingabe hier garantiert eine + * gueltige Dezimalzahl ist -- die Schleife oben hat sie abgegrenzt. */ + return std::stod(std::string{text.substr(start, pos - start)}); + } +}; + +} /* namespace */ + +std::expected +evaluate(std::string_view expression) +{ + Parser parser{expression}; + return parser.parse(); +} + +std::string +format_error(const EvalError& error) +{ + /* +1, weil Benutzer ab 1 zaehlen, der Parser ab 0. */ + return std::format("Spalte {}: {}", error.position + 1U, error.message); +} + +} /* namespace pda */ diff --git a/libpda/libpda/calculator.hpp b/libpda/libpda/calculator.hpp new file mode 100644 index 0000000..1698241 --- /dev/null +++ b/libpda/libpda/calculator.hpp @@ -0,0 +1,54 @@ +/* libpda/calculator.hpp + * Auswertung arithmetischer Ausdruecke. + * + * Die oeffentliche API ist bewusst EINE Funktion. Tokenizer und Parser stehen + * vollstaendig in der .cpp -- wer die Bibliothek benutzt, soll einen Ausdruck + * hineingeben und eine Zahl herausbekommen, nicht eine Grammatik lernen. + * + * Grammatik (Recursive Descent, Praezedenz von unten nach oben): + * + * expression := term (('+' | '-') term)* + * term := power (('*' | '/' | '%') power)* + * power := unary ('^' power)? rechtsassoziativ + * unary := ('+' | '-') unary | primary + * primary := number | '(' expression ')' + */ +#ifndef LIBPDA_CALCULATOR_HPP +#define LIBPDA_CALCULATOR_HPP + +#include +#include +#include +#include + +/* Definiert PDA_EXPORT. Die Datei wird von CMake erzeugt + * (generate_export_header) und liegt im Build-Baum bzw. nach dem + * Installieren neben diesem Header. */ +#include + +namespace pda +{ + +/* Position ist der Byte-Offset in der Eingabe, an dem der Fehler bemerkt + * wurde -- damit kann ein Aufrufer einen Zeiger darunter setzen. */ +struct EvalError +{ + std::size_t position; + std::string message; +}; + +/* Wertet einen Ausdruck aus. + * + * std::expected statt Exception: ein Tippfehler des Benutzers ist der + * Normalfall dieser Funktion, kein Ausnahmezustand. Der Aufrufer MUSS das + * Ergebnis pruefen -- [[nodiscard]] sorgt dafuer, dass er es nicht vergisst. */ +[[nodiscard]] PDA_EXPORT std::expected +evaluate(std::string_view expression); + +/* Formatiert einen Fehler als "Spalte N: Meldung". */ +[[nodiscard]] PDA_EXPORT std::string +format_error(const EvalError& error); + +} /* namespace pda */ + +#endif /* LIBPDA_CALCULATOR_HPP */ diff --git a/libpda/libpda/calculator.test.cpp b/libpda/libpda/calculator.test.cpp new file mode 100644 index 0000000..b53ba14 --- /dev/null +++ b/libpda/libpda/calculator.test.cpp @@ -0,0 +1,127 @@ +/* libpda/calculator.test.cpp + * Unit-Tests fuer den Ausdrucksparser (doctest). + */ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include + +#include + +namespace +{ + +/* Kleine Helfer, damit die Testfaelle selbst lesbar bleiben. */ +double +value_of(std::string_view expression) +{ + const auto result = pda::evaluate(expression); + + /* Fehlalarm aus doctests Makro-Innenleben, nicht aus unserem Code: der + * Analyzer verliert den Besitz an der Ausdrucks-Zerlegung von + * REQUIRE_MESSAGE. Der Test selbst allokiert nichts. */ + /* NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) */ + REQUIRE_MESSAGE(result.has_value(), expression); + return *result; +} + +pda::EvalError +error_of(std::string_view expression) +{ + const auto result = pda::evaluate(expression); + + /* Gleicher doctest-interner Fehlalarm wie in value_of. */ + /* NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) */ + REQUIRE_FALSE_MESSAGE(result.has_value(), expression); + return result.error(); +} + +} /* namespace */ + +TEST_CASE("plain numbers evaluate to themselves") +{ + CHECK(value_of("0") == doctest::Approx(0.0)); + CHECK(value_of("42") == doctest::Approx(42.0)); + CHECK(value_of("3.5") == doctest::Approx(3.5)); + CHECK(value_of(" 7 ") == doctest::Approx(7.0)); +} + +TEST_CASE("the four basic operations work") +{ + CHECK(value_of("1 + 2") == doctest::Approx(3.0)); + CHECK(value_of("9 - 4") == doctest::Approx(5.0)); + CHECK(value_of("6 * 7") == doctest::Approx(42.0)); + CHECK(value_of("8 / 2") == doctest::Approx(4.0)); + CHECK(value_of("7 % 3") == doctest::Approx(1.0)); +} + +TEST_CASE("multiplication binds tighter than addition") +{ + CHECK(value_of("2 + 3 * 4") == doctest::Approx(14.0)); + CHECK(value_of("2 * 3 + 4") == doctest::Approx(10.0)); +} + +TEST_CASE("parentheses override precedence") +{ + CHECK(value_of("(2 + 3) * 4") == doctest::Approx(20.0)); + CHECK(value_of("2 * (3 + 4)") == doctest::Approx(14.0)); + CHECK(value_of("((((5))))") == doctest::Approx(5.0)); +} + +TEST_CASE("subtraction and division are left associative") +{ + /* Der haeufigste Parser-Fehler: 10-3-2 als 10-(3-2) = 9 statt 5. */ + CHECK(value_of("10 - 3 - 2") == doctest::Approx(5.0)); + CHECK(value_of("100 / 5 / 2") == doctest::Approx(10.0)); +} + +TEST_CASE("exponentiation is right associative") +{ + /* Der zweithaeufigste: 2^3^2 als (2^3)^2 = 64 statt 2^(3^2) = 512. */ + CHECK(value_of("2 ^ 3 ^ 2") == doctest::Approx(512.0)); + CHECK(value_of("2 ^ 10") == doctest::Approx(1024.0)); +} + +TEST_CASE("unary minus works, also stacked") +{ + CHECK(value_of("-5") == doctest::Approx(-5.0)); + CHECK(value_of("--5") == doctest::Approx(5.0)); + CHECK(value_of("3 * -2") == doctest::Approx(-6.0)); + CHECK(value_of("-(2 + 3)") == doctest::Approx(-5.0)); + CHECK(value_of("+7") == doctest::Approx(7.0)); +} + +TEST_CASE("division by zero is reported, not returned as infinity") +{ + const auto error = error_of("1 / 0"); + CHECK(error.message == "Division durch null"); + + const auto modulo = error_of("5 % 0"); + CHECK(modulo.message == "Division durch null"); +} + +TEST_CASE("a missing closing parenthesis is reported") +{ + const auto error = error_of("(1 + 2"); + CHECK(error.message == "schliessende Klammer fehlt"); +} + +TEST_CASE("trailing garbage is rejected rather than ignored") +{ + /* Wichtig: ein Parser, der hier 1 zurueckgibt, verschluckt Tippfehler. */ + const auto error = error_of("1 2"); + CHECK(error.position == 2U); +} + +TEST_CASE("an empty expression is an error, not zero") +{ + const auto error = error_of(""); + CHECK(error.message == "Zahl erwartet, Eingabe zu Ende"); +} + +TEST_CASE("the error position points at the offending column") +{ + const auto error = error_of("1 + * 2"); + CHECK(error.position == 4U); + CHECK(pda::format_error(error).starts_with("Spalte 5:")); +} diff --git a/libpda/libpda/contact.cpp b/libpda/libpda/contact.cpp new file mode 100644 index 0000000..be49a46 --- /dev/null +++ b/libpda/libpda/contact.cpp @@ -0,0 +1,281 @@ +/* libpda/contact.cpp + * Implementierung der Kontaktverwaltung. + */ +#include +#include +#include +#include +#include + +#include +#include + +namespace pda +{ +namespace +{ + +/* RAII-Huelle um die char*, die textfile_escape zurueckgibt. Ohne die muesste + * jeder Fehlerpfad unten von Hand freigeben -- und genau dort entstehen Lecks. + * + * unique_ptr mit eigenem Deleter statt einer eigenen Klasse: weniger Code und + * die Absicht steht in der Typangabe. */ +struct FreeCString +{ + void + operator()(char* text) const noexcept + { + textfile_free_string(text); + } +}; + +using CStringPtr = std::unique_ptr; + +[[nodiscard]] CStringPtr +escape(const std::string& field) +{ + return CStringPtr{textfile_escape(field.c_str())}; +} + +[[nodiscard]] CStringPtr +unescape(const std::string& field) +{ + return CStringPtr{textfile_unescape(field.c_str())}; +} + +[[nodiscard]] std::string +to_lower(std::string_view text) +{ + std::string out; + out.reserve(text.size()); + + /* static_cast ist Pflicht: std::tolower mit einem negativen + * char ist undefiniert, und genau das liefern Umlaute in Latin-1. */ + for (const char c : text) + out.push_back(static_cast(std::tolower(static_cast(c)))); + + return out; +} + +[[nodiscard]] bool +contains_fold(std::string_view haystack, std::string_view needle) +{ + return to_lower(haystack).contains(to_lower(needle)); +} + +/* Zerlegt eine Zeile an TABs. Anders als bei einem Split, der leere Felder + * verwirft, bleiben sie hier erhalten -- ein Kontakt ohne E-Mail hat ein + * leeres Feld, keine fehlende Spalte. */ +[[nodiscard]] std::vector +split_tabs(std::string_view line) +{ + std::vector fields; + std::size_t start = 0U; + + for (;;) + { + const std::size_t tab = line.find('\t', start); + if (tab == std::string_view::npos) + { + fields.emplace_back(line.substr(start)); + return fields; + } + + fields.emplace_back(line.substr(start, tab - start)); + start = tab + 1U; + } +} + +} /* namespace */ + +std::string_view +describe(ContactError error) noexcept +{ + switch (error) + { + case ContactError::file_not_readable: + return "Datei nicht lesbar"; + case ContactError::file_not_writable: + return "Datei nicht schreibbar"; + case ContactError::malformed_record: + return "Datensatz unvollstaendig"; + } + + return "unbekannter Fehler"; +} + +std::vector::const_iterator +ContactBook::locate(std::string_view name) const noexcept +{ + return std::find_if(entries.begin(), entries.end(), + [name](const Contact& contact) { return contact.name == name; }); +} + +bool +ContactBook::add(Contact contact) +{ + if (locate(contact.name) != entries.end()) return false; + + entries.push_back(std::move(contact)); + return true; +} + +bool +ContactBook::update(Contact contact) +{ + const auto it = locate(contact.name); + if (it == entries.end()) return false; + + /* const_iterator -> iterator ueber den Abstand: entries ist nicht const, + * nur locate() liefert const_iterator. */ + entries[static_cast(it - entries.begin())] = std::move(contact); + return true; +} + +bool +ContactBook::remove(std::string_view name) +{ + const auto it = locate(name); + if (it == entries.end()) return false; + + entries.erase(it); + return true; +} + +const Contact* +ContactBook::find(std::string_view name) const noexcept +{ + const auto it = locate(name); + return it == entries.end() ? nullptr : &*it; +} + +std::vector +ContactBook::search(std::string_view needle) const +{ + std::vector hits; + + for (const Contact& contact : entries) + { + if (contains_fold(contact.name, needle) || contains_fold(contact.phone, needle) || + contains_fold(contact.email, needle) || contains_fold(contact.note, needle)) + { + hits.push_back(contact); + } + } + + return hits; +} + +const std::vector& +ContactBook::all() const noexcept +{ + return entries; +} + +std::size_t +ContactBook::size() const noexcept +{ + return entries.size(); +} + +bool +ContactBook::empty() const noexcept +{ + return entries.empty(); +} + +void +ContactBook::clear() noexcept +{ + entries.clear(); +} + +std::expected +ContactBook::save(const std::filesystem::path& path) const +{ + std::string out; + + for (const Contact& contact : entries) + { + const CStringPtr name = escape(contact.name); + const CStringPtr phone = escape(contact.phone); + const CStringPtr email = escape(contact.email); + const CStringPtr note = escape(contact.note); + + if (!name || !phone || !email || !note) + { + return std::unexpected{ContactError::file_not_writable}; + } + + out += name.get(); + out += '\t'; + out += phone.get(); + out += '\t'; + out += email.get(); + out += '\t'; + out += note.get(); + out += '\n'; + } + + const TextfileStatus status = textfile_write(path.c_str(), out.data(), out.size()); + if (status != TEXTFILE_OK) return std::unexpected{ContactError::file_not_writable}; + + return {}; +} + +std::expected +ContactBook::load(const std::filesystem::path& path) +{ + TextBlob blob; + if (textfile_read(path.c_str(), &blob) != TEXTFILE_OK) + { + return std::unexpected{ContactError::file_not_readable}; + } + + /* Der Blob muss auf JEDEM Pfad hier drunter freigegeben werden -- deshalb + * sofort in einen unique_ptr, statt an drei Stellen daran zu denken. */ + const std::unique_ptr guard{&blob, textfile_blob_free}; + + const std::string_view content{blob.data, blob.size}; + std::vector loaded; + + std::size_t start = 0U; + while (start < content.size()) + { + std::size_t end = content.find('\n', start); + if (end == std::string_view::npos) end = content.size(); + + const std::string_view line = content.substr(start, end - start); + start = end + 1U; + + if (line.empty()) continue; + + const std::vector fields = split_tabs(line); + if (fields.size() != 4U) return std::unexpected{ContactError::malformed_record}; + + Contact contact; + const CStringPtr name = unescape(fields[0]); + const CStringPtr phone = unescape(fields[1]); + const CStringPtr email = unescape(fields[2]); + const CStringPtr note = unescape(fields[3]); + + if (!name || !phone || !email || !note) + { + return std::unexpected{ContactError::malformed_record}; + } + + contact.name = name.get(); + contact.phone = phone.get(); + contact.email = email.get(); + contact.note = note.get(); + + loaded.push_back(std::move(contact)); + } + + /* Erst hier ersetzen: bis zu diesem Punkt konnte noch ein Fehler kommen, + * und der ContactBook soll dann unveraendert sein. */ + entries = std::move(loaded); + return {}; +} + +} /* namespace pda */ diff --git a/libpda/libpda/contact.hpp b/libpda/libpda/contact.hpp new file mode 100644 index 0000000..1d6bdd8 --- /dev/null +++ b/libpda/libpda/contact.hpp @@ -0,0 +1,107 @@ +/* libpda/contact.hpp + * Kontaktverwaltung. + * + * Zeigt die Naht zwischen den Sprachen: die Persistenz laeuft ueber + * (C), aber nach aussen ist davon nichts zu sehen -- kein + * char*, kein Freigeben, keine Fehlercodes. Genau das ist die Aufgabe einer + * C++-Schicht ueber einer C-Bibliothek. + */ +#ifndef LIBPDA_CONTACT_HPP +#define LIBPDA_CONTACT_HPP + +#include +#include +#include +#include +#include +#include + +/* Definiert PDA_EXPORT. Die Datei wird von CMake erzeugt + * (generate_export_header) und liegt im Build-Baum bzw. nach dem + * Installieren neben diesem Header. */ +#include + +namespace pda +{ + +struct Contact +{ + std::string name; /* Schluessel, eindeutig innerhalb eines ContactBook */ + std::string phone; + std::string email; + std::string note; +}; + +/* Warum kein einfaches enum: der Aufrufer soll den Grund unterscheiden + * koennen, ohne die Meldung zu parsen. */ +enum class ContactError +{ + file_not_readable, + file_not_writable, + malformed_record +}; + +[[nodiscard]] PDA_EXPORT std::string_view +describe(ContactError error) noexcept; + +class PDA_EXPORT ContactBook +{ +public: + /* Legt an. false, wenn der Name schon vergeben ist -- ein vorhandener + * Eintrag wird NICHT ueberschrieben (dafuer gibt es update). */ + bool + add(Contact contact); + + /* Ersetzt einen vorhandenen Eintrag. false, wenn es ihn nicht gibt. */ + bool + update(Contact contact); + + bool + remove(std::string_view name); + + /* nullptr, wenn unbekannt. Der Zeiger gilt, bis der ContactBook veraendert + * wird -- genau wie bei std::vector. */ + [[nodiscard]] const Contact* + find(std::string_view name) const noexcept; + + /* Teilstring-Suche ueber alle Felder, Gross-/Kleinschreibung egal. + * Gibt Kopien zurueck: der Aufrufer soll das Ergebnis behalten duerfen, + * auch wenn danach etwas eingefuegt wird. */ + [[nodiscard]] std::vector + search(std::string_view needle) const; + + [[nodiscard]] const std::vector& + all() const noexcept; + + [[nodiscard]] std::size_t + size() const noexcept; + + [[nodiscard]] bool + empty() const noexcept; + + void + clear() noexcept; + + /* ---- Persistenz ---- */ + + /* Ein Datensatz je Zeile, Felder durch TAB getrennt, Sonderzeichen + * escapet (siehe textfile.h). Das Format ist absichtlich mit grep und + * einem Texteditor zu bearbeiten. */ + [[nodiscard]] std::expected + save(const std::filesystem::path& path) const; + + /* Ersetzt den bisherigen Inhalt. Bei einem Fehler bleibt der ContactBook + * unveraendert -- geladen wird erst in eine lokale Kopie. */ + [[nodiscard]] std::expected + load(const std::filesystem::path& path); + +private: + std::vector entries; + + [[nodiscard]] std::vector::const_iterator + locate(std::string_view name) const noexcept; +}; + +} /* namespace pda */ + +#endif /* LIBPDA_CONTACT_HPP */ diff --git a/libpda/libpda/contact.test.cpp b/libpda/libpda/contact.test.cpp new file mode 100644 index 0000000..8af5e5a --- /dev/null +++ b/libpda/libpda/contact.test.cpp @@ -0,0 +1,194 @@ +/* libpda/contact.test.cpp + * Unit-Tests fuer die Kontaktverwaltung (doctest). + */ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include + +#include + +namespace +{ + +pda::Contact +make(std::string name, std::string phone = "", std::string email = "", std::string note = "") +{ + return pda::Contact{std::move(name), std::move(phone), std::move(email), std::move(note)}; +} + +/* Legt einen Pfad im aktuellen Verzeichnis an und raeumt ihn im Destruktor + * wieder weg -- auch wenn ein CHECK dazwischen fehlschlaegt. */ +class TempFile +{ +public: + explicit TempFile(const char* name) : path{std::filesystem::current_path() / name} {} + + ~TempFile() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + + TempFile(const TempFile&) = delete; + TempFile& + operator=(const TempFile&) = delete; + + const std::filesystem::path& + get() const noexcept + { + return path; + } + +private: + std::filesystem::path path; +}; + +} /* namespace */ + +TEST_CASE("a fresh book is empty") +{ + const pda::ContactBook book; + + CHECK(book.empty()); + CHECK(book.size() == 0U); + CHECK(book.find("niemand") == nullptr); +} + +TEST_CASE("add stores a contact that find returns") +{ + pda::ContactBook book; + REQUIRE(book.add(make("Anna", "0151", "anna@example.org", "Schwester"))); + + const pda::Contact* found = book.find("Anna"); + REQUIRE(found != nullptr); + CHECK(found->phone == "0151"); + CHECK(found->note == "Schwester"); +} + +TEST_CASE("add refuses a duplicate name") +{ + pda::ContactBook book; + REQUIRE(book.add(make("Anna", "0151"))); + CHECK_FALSE(book.add(make("Anna", "0170"))); + + /* Der urspruengliche Eintrag muss unveraendert bleiben. */ + CHECK(book.find("Anna")->phone == "0151"); + CHECK(book.size() == 1U); +} + +TEST_CASE("update replaces an existing contact but not a missing one") +{ + pda::ContactBook book; + book.add(make("Anna", "0151")); + + CHECK(book.update(make("Anna", "0170"))); + CHECK(book.find("Anna")->phone == "0170"); + + CHECK_FALSE(book.update(make("Bert", "0123"))); + CHECK(book.size() == 1U); +} + +TEST_CASE("remove deletes only the named contact") +{ + pda::ContactBook book; + book.add(make("Anna")); + book.add(make("Bert")); + + CHECK(book.remove("Anna")); + CHECK_FALSE(book.remove("Anna")); + CHECK(book.size() == 1U); + CHECK(book.find("Bert") != nullptr); +} + +TEST_CASE("search matches any field, ignoring case") +{ + pda::ContactBook book; + book.add(make("Anna", "0151", "anna@example.org", "Schwester")); + book.add(make("Bert", "0170", "bert@firma.de", "Kollege")); + + CHECK(book.search("ANNA").size() == 1U); + CHECK(book.search("firma").size() == 1U); + CHECK(book.search("01").size() == 2U); + CHECK(book.search("nichts").empty()); +} + +TEST_CASE("save and load round-trip preserves every field") +{ + const TempFile file{"contacts.roundtrip.tmp"}; + + pda::ContactBook original; + original.add(make("Anna", "0151", "anna@example.org", "Schwester")); + original.add(make("Bert", "0170", "bert@firma.de", "Kollege")); + + REQUIRE(original.save(file.get()).has_value()); + + pda::ContactBook loaded; + REQUIRE(loaded.load(file.get()).has_value()); + + REQUIRE(loaded.size() == 2U); + CHECK(loaded.find("Anna")->email == "anna@example.org"); + CHECK(loaded.find("Bert")->note == "Kollege"); +} + +TEST_CASE("fields containing tabs and newlines survive a round trip") +{ + /* Der eigentliche Grund fuer das Escaping in textfile.c: ohne das + * zerfaellt dieser Datensatz beim Laden in drei kaputte Zeilen. */ + const TempFile file{"contacts.escaping.tmp"}; + + pda::ContactBook original; + original.add(make("Anna", "0151", "a@b.c", "Zeile1\nZeile2\tSpalte\\Ende")); + + REQUIRE(original.save(file.get()).has_value()); + + pda::ContactBook loaded; + REQUIRE(loaded.load(file.get()).has_value()); + + REQUIRE(loaded.size() == 1U); + CHECK(loaded.find("Anna")->note == "Zeile1\nZeile2\tSpalte\\Ende"); +} + +TEST_CASE("loading a missing file reports an error") +{ + pda::ContactBook book; + const auto result = book.load("gibt/es/nicht.tsv"); + + REQUIRE_FALSE(result.has_value()); + CHECK(result.error() == pda::ContactError::file_not_readable); +} + +TEST_CASE("a failed load leaves the book untouched") +{ + pda::ContactBook book; + book.add(make("Anna", "0151")); + + const auto result = book.load("gibt/es/nicht.tsv"); + REQUIRE_FALSE(result.has_value()); + + /* Das ist die Zusicherung aus contact.hpp -- ein halb geladener + * ContactBook waere schlimmer als gar keiner. */ + CHECK(book.size() == 1U); + CHECK(book.find("Anna") != nullptr); +} + +TEST_CASE("an empty book saves and loads as empty") +{ + const TempFile file{"contacts.empty.tmp"}; + + const pda::ContactBook original; + REQUIRE(original.save(file.get()).has_value()); + + pda::ContactBook loaded; + loaded.add(make("wird geloescht")); + REQUIRE(loaded.load(file.get()).has_value()); + + CHECK(loaded.empty()); +} + +TEST_CASE("describe covers every error value") +{ + CHECK(pda::describe(pda::ContactError::file_not_readable) == "Datei nicht lesbar"); + CHECK(pda::describe(pda::ContactError::file_not_writable) == "Datei nicht schreibbar"); + CHECK(pda::describe(pda::ContactError::malformed_record) == "Datensatz unvollstaendig"); +} diff --git a/libpda/libpda/details/version.hpp b/libpda/libpda/details/version.hpp new file mode 100644 index 0000000..f956f28 --- /dev/null +++ b/libpda/libpda/details/version.hpp @@ -0,0 +1,34 @@ +/* libpda/details/version.hpp + * Versionsinformation der Bibliothek. + * + * details/ ist nach P1204R0 die mittlere Ebene: mitinstalliert und benutzbar, + * aber ausdruecklich nicht Teil der stabilen oeffentlichen API. Wer + * inkludiert, weiss, dass er sich auf Internes stuetzt. + */ +#ifndef LIBPDA_DETAILS_VERSION_HPP +#define LIBPDA_DETAILS_VERSION_HPP + +#include + +namespace pda::details +{ + +/* Diese Konstanten stehen bewusst NICHT in einer generierten Datei. Eine + * version.hpp.in mit @PROJECT_VERSION@ waere die uebliche Antwort, aber sie + * hat einen Preis: der Header existiert dann erst nach dem Konfigurieren, und + * clangd zeigt in einem frischen Clone ueberall rote Wellenlinien. Fuer drei + * Zahlen ist das kein guter Tausch. + * + * Wenn hier je etwas dazukommt, das der Build wirklich wissen muss (ein + * Git-Hash zum Beispiel), gehoert DAS in eine generierte Datei -- und diese + * hier bleibt, wie sie ist. + */ +inline constexpr int version_major = 0; +inline constexpr int version_minor = 1; +inline constexpr int version_patch = 0; + +inline constexpr std::string_view version_string = "0.1.0"; + +} /* namespace pda::details */ + +#endif /* LIBPDA_DETAILS_VERSION_HPP */ diff --git a/libpda/libpda/editor.cpp b/libpda/libpda/editor.cpp new file mode 100644 index 0000000..287047d --- /dev/null +++ b/libpda/libpda/editor.cpp @@ -0,0 +1,193 @@ +/* libpda/editor.cpp + * Implementierung des Textpuffers. + */ +#include +#include + +#include +#include + +namespace pda +{ + +std::string_view +describe(EditorError error) noexcept +{ + switch (error) + { + case EditorError::file_not_readable: + return "Datei nicht lesbar"; + case EditorError::file_not_writable: + return "Datei nicht schreibbar"; + case EditorError::line_out_of_range: + return "Zeilennummer ausserhalb des Puffers"; + } + + return "unbekannter Fehler"; +} + +std::expected +TextBuffer::index_of(std::size_t number) const noexcept +{ + if (number == 0U || number > rows.size()) + { + return std::unexpected{EditorError::line_out_of_range}; + } + + return number - 1U; +} + +std::size_t +TextBuffer::line_count() const noexcept +{ + return rows.size(); +} + +bool +TextBuffer::empty() const noexcept +{ + return rows.empty(); +} + +bool +TextBuffer::modified() const noexcept +{ + return dirty; +} + +const std::vector& +TextBuffer::lines() const noexcept +{ + return rows; +} + +std::expected +TextBuffer::line(std::size_t number) const +{ + const auto index = index_of(number); + if (!index) return std::unexpected{index.error()}; + + return std::string_view{rows[*index]}; +} + +void +TextBuffer::append(std::string text) +{ + rows.push_back(std::move(text)); + dirty = true; +} + +std::expected +TextBuffer::insert(std::size_t number, std::string text) +{ + /* Anhaengen ist erlaubt und ausdruecklich KEIN Fehler: sonst braeuchte + * jeder Aufrufer eine Sonderbehandlung fuer "ans Ende einfuegen". */ + if (number == rows.size() + 1U) + { + append(std::move(text)); + return {}; + } + + const auto index = index_of(number); + if (!index) return std::unexpected{index.error()}; + + rows.insert(rows.begin() + static_cast(*index), std::move(text)); + dirty = true; + return {}; +} + +std::expected +TextBuffer::replace(std::size_t number, std::string text) +{ + const auto index = index_of(number); + if (!index) return std::unexpected{index.error()}; + + rows[*index] = std::move(text); + dirty = true; + return {}; +} + +std::expected +TextBuffer::erase(std::size_t number) +{ + const auto index = index_of(number); + if (!index) return std::unexpected{index.error()}; + + rows.erase(rows.begin() + static_cast(*index)); + dirty = true; + return {}; +} + +void +TextBuffer::clear() noexcept +{ + rows.clear(); + dirty = true; +} + +std::string +TextBuffer::text() const +{ + std::string out; + for (const std::string& row : rows) + { + out += row; + out += '\n'; + } + + return out; +} + +std::expected +TextBuffer::load(const std::filesystem::path& path) +{ + TextBlob blob; + if (textfile_read(path.c_str(), &blob) != TEXTFILE_OK) + { + return std::unexpected{EditorError::file_not_readable}; + } + + const std::unique_ptr guard{&blob, textfile_blob_free}; + + std::vector loaded; + const std::string_view content{blob.data, blob.size}; + + std::size_t start = 0U; + while (start < content.size()) + { + std::size_t end = content.find('\n', start); + if (end == std::string_view::npos) end = content.size(); + + std::string_view row = content.substr(start, end - start); + + /* CRLF-Dateien: das '\r' gehoert nicht zum Text, sonst haengt an + * jeder Zeile ein unsichtbares Zeichen. */ + if (!row.empty() && row.back() == '\r') row.remove_suffix(1U); + + loaded.emplace_back(row); + start = end + 1U; + } + + rows = std::move(loaded); + + /* Frisch geladen heisst unveraendert -- sonst warnt die Anwendung beim + * Beenden, obwohl nichts passiert ist. */ + dirty = false; + return {}; +} + +std::expected +TextBuffer::save(const std::filesystem::path& path) +{ + const std::string content = text(); + + if (textfile_write(path.c_str(), content.data(), content.size()) != TEXTFILE_OK) + { + return std::unexpected{EditorError::file_not_writable}; + } + + dirty = false; + return {}; +} + +} /* namespace pda */ diff --git a/libpda/libpda/editor.hpp b/libpda/libpda/editor.hpp new file mode 100644 index 0000000..95737d3 --- /dev/null +++ b/libpda/libpda/editor.hpp @@ -0,0 +1,106 @@ +/* libpda/editor.hpp + * Zeilenorientierter Textpuffer. + * + * Bewusst KEIN Bildschirm, keine Tastatur, kein ncurses: der Puffer ist reine + * Logik und damit ohne Terminal testbar. Die Anzeige gehoert in die + * Anwendung (pda/), nicht in die Bibliothek -- sonst kann man die Bibliothek + * nur noch in einem Terminal benutzen. + * + * Zeilen werden ab 1 gezaehlt, wie in jedem Editor. Intern ab 0. + */ +#ifndef LIBPDA_EDITOR_HPP +#define LIBPDA_EDITOR_HPP + +#include +#include +#include +#include +#include +#include + +/* Definiert PDA_EXPORT. Die Datei wird von CMake erzeugt + * (generate_export_header) und liegt im Build-Baum bzw. nach dem + * Installieren neben diesem Header. */ +#include + +namespace pda +{ + +enum class EditorError +{ + file_not_readable, + file_not_writable, + line_out_of_range +}; + +[[nodiscard]] PDA_EXPORT std::string_view +describe(EditorError error) noexcept; + +class PDA_EXPORT TextBuffer +{ +public: + /* ---- Zustand ---- */ + + [[nodiscard]] std::size_t + line_count() const noexcept; + + [[nodiscard]] bool + empty() const noexcept; + + /* true, sobald seit dem letzten load/save veraendert wurde. Die Anwendung + * braucht das, um vor dem Beenden zu warnen. */ + [[nodiscard]] bool + modified() const noexcept; + + [[nodiscard]] const std::vector& + lines() const noexcept; + + /* 1-basiert. line_out_of_range, wenn es die Zeile nicht gibt. */ + [[nodiscard]] std::expected + line(std::size_t number) const; + + /* ---- Veraendern ---- */ + + /* Haengt hinten an. */ + void + append(std::string text); + + /* Fuegt VOR der angegebenen Zeile ein. number == line_count() + 1 haengt + * an -- damit ist Einfuegen am Ende kein Sonderfall fuer den Aufrufer. */ + [[nodiscard]] std::expected + insert(std::size_t number, std::string text); + + [[nodiscard]] std::expected + replace(std::size_t number, std::string text); + + [[nodiscard]] std::expected + erase(std::size_t number); + + void + clear() noexcept; + + /* ---- Zusammensetzen und Persistenz ---- */ + + /* Alle Zeilen mit '\n' verbunden, mit abschliessendem '\n', wenn es + * ueberhaupt Zeilen gibt. Ein leerer Puffer ergibt "". */ + [[nodiscard]] std::string + text() const; + + [[nodiscard]] std::expected + load(const std::filesystem::path& path); + + [[nodiscard]] std::expected + save(const std::filesystem::path& path); + +private: + std::vector rows; + bool dirty{false}; + + /* Prueft eine 1-basierte Zeilennummer und liefert den 0-basierten Index. */ + [[nodiscard]] std::expected + index_of(std::size_t number) const noexcept; +}; + +} /* namespace pda */ + +#endif /* LIBPDA_EDITOR_HPP */ diff --git a/libpda/libpda/editor.test.cpp b/libpda/libpda/editor.test.cpp new file mode 100644 index 0000000..7c6a15e --- /dev/null +++ b/libpda/libpda/editor.test.cpp @@ -0,0 +1,174 @@ +/* libpda/editor.test.cpp + * Unit-Tests fuer den Textpuffer (doctest). + */ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include + +#include + +namespace +{ + +class TempFile +{ +public: + explicit TempFile(const char* name) : path{std::filesystem::current_path() / name} {} + + ~TempFile() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + + TempFile(const TempFile&) = delete; + TempFile& + operator=(const TempFile&) = delete; + + const std::filesystem::path& + get() const noexcept + { + return path; + } + +private: + std::filesystem::path path; +}; + +pda::TextBuffer +with_lines() +{ + pda::TextBuffer buffer; + buffer.append("erste"); + buffer.append("zweite"); + buffer.append("dritte"); + return buffer; +} + +} /* namespace */ + +TEST_CASE("a fresh buffer is empty and unmodified") +{ + const pda::TextBuffer buffer; + + CHECK(buffer.empty()); + CHECK(buffer.line_count() == 0U); + CHECK_FALSE(buffer.modified()); + CHECK(buffer.text().empty()); +} + +TEST_CASE("append adds lines in order and marks the buffer modified") +{ + const pda::TextBuffer buffer = with_lines(); + + REQUIRE(buffer.line_count() == 3U); + CHECK(buffer.modified()); + CHECK(*buffer.line(1U) == "erste"); + CHECK(*buffer.line(3U) == "dritte"); +} + +TEST_CASE("line numbers are one-based and bounds-checked") +{ + const pda::TextBuffer buffer = with_lines(); + + /* Zeile 0 gibt es nicht -- der haeufigste Off-by-one in Editoren. */ + CHECK_FALSE(buffer.line(0U).has_value()); + CHECK_FALSE(buffer.line(4U).has_value()); + CHECK(buffer.line(0U).error() == pda::EditorError::line_out_of_range); +} + +TEST_CASE("insert places text before the given line") +{ + pda::TextBuffer buffer = with_lines(); + REQUIRE(buffer.insert(2U, "dazwischen").has_value()); + + REQUIRE(buffer.line_count() == 4U); + CHECK(*buffer.line(1U) == "erste"); + CHECK(*buffer.line(2U) == "dazwischen"); + CHECK(*buffer.line(3U) == "zweite"); +} + +TEST_CASE("insert one past the end appends instead of failing") +{ + pda::TextBuffer buffer = with_lines(); + + /* Ausdrueckliche Zusicherung aus editor.hpp: sonst braeuchte jeder + * Aufrufer einen Sonderfall fuer "ans Ende". */ + REQUIRE(buffer.insert(4U, "vierte").has_value()); + CHECK(buffer.line_count() == 4U); + CHECK(*buffer.line(4U) == "vierte"); + + CHECK_FALSE(buffer.insert(6U, "zu weit").has_value()); +} + +TEST_CASE("replace and erase address the right line") +{ + pda::TextBuffer buffer = with_lines(); + + REQUIRE(buffer.replace(2U, "ZWEITE").has_value()); + CHECK(*buffer.line(2U) == "ZWEITE"); + + REQUIRE(buffer.erase(1U).has_value()); + REQUIRE(buffer.line_count() == 2U); + CHECK(*buffer.line(1U) == "ZWEITE"); + + CHECK_FALSE(buffer.erase(99U).has_value()); +} + +TEST_CASE("text joins lines with a trailing newline") +{ + const pda::TextBuffer buffer = with_lines(); + CHECK(buffer.text() == "erste\nzweite\ndritte\n"); +} + +TEST_CASE("save then load round-trips the content") +{ + const TempFile file{"editor.roundtrip.tmp"}; + + pda::TextBuffer original = with_lines(); + REQUIRE(original.save(file.get()).has_value()); + + /* Speichern setzt das Modified-Flag zurueck. */ + CHECK_FALSE(original.modified()); + + pda::TextBuffer loaded; + REQUIRE(loaded.load(file.get()).has_value()); + + CHECK(loaded.line_count() == 3U); + CHECK(loaded.text() == original.text()); + CHECK_FALSE(loaded.modified()); +} + +TEST_CASE("loading strips carriage returns from CRLF files") +{ + const TempFile file{"editor.crlf.tmp"}; + + pda::TextBuffer writer; + writer.append("eins\r"); + writer.append("zwei\r"); + REQUIRE(writer.save(file.get()).has_value()); + + pda::TextBuffer loaded; + REQUIRE(loaded.load(file.get()).has_value()); + + CHECK(*loaded.line(1U) == "eins"); + CHECK(*loaded.line(2U) == "zwei"); +} + +TEST_CASE("loading a missing file reports an error") +{ + pda::TextBuffer buffer; + const auto result = buffer.load("gibt/es/nicht.txt"); + + REQUIRE_FALSE(result.has_value()); + CHECK(result.error() == pda::EditorError::file_not_readable); +} + +TEST_CASE("describe covers every error value") +{ + CHECK(pda::describe(pda::EditorError::file_not_readable) == "Datei nicht lesbar"); + CHECK(pda::describe(pda::EditorError::file_not_writable) == "Datei nicht schreibbar"); + CHECK(pda::describe(pda::EditorError::line_out_of_range) == + "Zeilennummer ausserhalb des Puffers"); +} diff --git a/libpda/libpda/explorer.cpp b/libpda/libpda/explorer.cpp new file mode 100644 index 0000000..8eb579b --- /dev/null +++ b/libpda/libpda/explorer.cpp @@ -0,0 +1,160 @@ +/* libpda/explorer.cpp + * Implementierung der Verzeichnisnavigation. + */ +#include +#include +#include +#include +#include + +#include + +namespace pda +{ +namespace +{ + +namespace fs = std::filesystem; + +/* Alle Zugriffe mit der error_code-Ueberladung: die werfende Variante wuerde + * bei einem Symlink ins Leere oder fehlenden Rechten eine Exception ausloesen, + * und das ist beim Durchblaettern eines Verzeichnisses der Normalfall. */ +[[nodiscard]] bool +is_hidden(const std::string& name) +{ + return !name.empty() && name.front() == '.'; +} + +} /* namespace */ + +std::string_view +describe(ExplorerError error) noexcept +{ + switch (error) + { + case ExplorerError::not_a_directory: + return "kein Verzeichnis"; + case ExplorerError::not_accessible: + return "nicht zugreifbar"; + } + + return "unbekannter Fehler"; +} + +Explorer::Explorer(fs::path start) : here{std::move(start)} {} + +const fs::path& +Explorer::current() const noexcept +{ + return here; +} + +std::expected, ExplorerError> +Explorer::list(bool include_hidden) const +{ + std::error_code ec; + + if (!fs::is_directory(here, ec) || ec) + { + return std::unexpected{ExplorerError::not_a_directory}; + } + + const fs::directory_iterator it{here, fs::directory_options::skip_permission_denied, ec}; + if (ec) return std::unexpected{ExplorerError::not_accessible}; + + std::vector entries; + + for (const fs::directory_entry& entry : it) + { + DirEntry item; + item.name = entry.path().filename().string(); + + if (!include_hidden && is_hidden(item.name)) continue; + + std::error_code entry_ec; + item.is_directory = entry.is_directory(entry_ec); + + if (!item.is_directory) + { + const std::uintmax_t size = entry.file_size(entry_ec); + + /* Bei einem Fehler bleibt die Groesse 0, statt den ganzen + * Listing-Aufruf scheitern zu lassen -- ein kaputter Symlink soll + * das Verzeichnis nicht unbenutzbar machen. */ + item.size = entry_ec ? 0U : size; + } + + entries.push_back(std::move(item)); + } + + /* Verzeichnisse zuerst, dann alphabetisch. Stabil ist hier egal, weil der + * Name eindeutig ist. */ + std::sort(entries.begin(), entries.end(), + [](const DirEntry& a, const DirEntry& b) + { + if (a.is_directory != b.is_directory) return a.is_directory; + return a.name < b.name; + }); + + return entries; +} + +std::expected +Explorer::enter(std::string_view name) +{ + if (name == ".." || name == "-") + { + up(); + return {}; + } + + return go(here / name); +} + +void +Explorer::up() +{ + const fs::path parent = here.parent_path(); + + /* Im Wurzelverzeichnis ist parent_path() gleich dem Pfad selbst -- ohne + * diese Pruefung wuerde "up" dort stumm nichts tun und trotzdem + * zuweisen. */ + if (!parent.empty() && parent != here) here = parent; +} + +std::expected +Explorer::go(const fs::path& path) +{ + std::error_code ec; + + if (!fs::exists(path, ec) || ec) return std::unexpected{ExplorerError::not_accessible}; + if (!fs::is_directory(path, ec) || ec) return std::unexpected{ExplorerError::not_a_directory}; + + /* Normalisiert "a/b/../c" zu "a/c" und loest Symlinks auf, damit up() + * danach das tut, was der Benutzer sieht. */ + const fs::path resolved = fs::weakly_canonical(path, ec); + here = ec ? path : resolved; + + return {}; +} + +std::string +human_size(std::uintmax_t bytes) +{ + constexpr std::array units{"B", "K", "M", "G", "T"}; + + if (bytes < 1024U) return std::format("{}{}", bytes, units[0]); + + double value = static_cast(bytes); + std::size_t unit = 0U; + + while (value >= 1024.0 && unit + 1U < units.size()) + { + value /= 1024.0; + unit++; + } + + return std::format("{:.1f}{}", value, units[unit]); +} + +} /* namespace pda */ diff --git a/libpda/libpda/explorer.hpp b/libpda/libpda/explorer.hpp new file mode 100644 index 0000000..d18c169 --- /dev/null +++ b/libpda/libpda/explorer.hpp @@ -0,0 +1,85 @@ +/* libpda/explorer.hpp + * Verzeichnisnavigation ueber std::filesystem. + * + * Die Klasse haelt ein "aktuelles Verzeichnis" -- aber NICHT das des + * Prozesses. std::filesystem::current_path() zu aendern wirkt global und + * macht zwei Explorer im selben Programm unmoeglich; hier ist der Zustand + * pro Objekt. + */ +#ifndef LIBPDA_EXPLORER_HPP +#define LIBPDA_EXPLORER_HPP + +#include +#include +#include +#include +#include +#include + +/* Definiert PDA_EXPORT. Die Datei wird von CMake erzeugt + * (generate_export_header) und liegt im Build-Baum bzw. nach dem + * Installieren neben diesem Header. */ +#include + +namespace pda +{ + +enum class PDA_EXPORT ExplorerError +{ + not_a_directory, + not_accessible +}; + +[[nodiscard]] PDA_EXPORT std::string_view +describe(ExplorerError error) noexcept; + +struct DirEntry +{ + std::string name; + bool is_directory{false}; + + /* Bei Verzeichnissen 0 -- die Groesse eines Verzeichniseintrags ist + * plattformabhaengig und sagt nichts ueber den Inhalt. */ + std::uintmax_t size{0U}; +}; + +class PDA_EXPORT Explorer +{ +public: + /* Startet im angegebenen Verzeichnis. Wirft nicht: bei einem ungueltigen + * Pfad bleibt current() leer und list() meldet den Fehler. */ + explicit Explorer(std::filesystem::path start); + + [[nodiscard]] const std::filesystem::path& + current() const noexcept; + + /* Eintraege des aktuellen Verzeichnisses: Verzeichnisse zuerst, dann + * alphabetisch. Versteckte Eintraege (fuehrender Punkt) nur, wenn + * include_hidden gesetzt ist. */ + [[nodiscard]] std::expected, ExplorerError> + list(bool include_hidden = false) const; + + /* Wechselt in ein Unterverzeichnis. "-" oder ".." geht nach oben. */ + [[nodiscard]] std::expected + enter(std::string_view name); + + /* Nach oben. Im Wurzelverzeichnis passiert nichts (kein Fehler). */ + void + up(); + + /* Absoluter Wechsel. */ + [[nodiscard]] std::expected + go(const std::filesystem::path& path); + +private: + std::filesystem::path here; +}; + +/* Menschenlesbare Groesse: 1536 -> "1.5K". Frei stehende Funktion, weil sie + * nichts ueber den Explorer wissen muss. */ +[[nodiscard]] PDA_EXPORT std::string +human_size(std::uintmax_t bytes); + +} /* namespace pda */ + +#endif /* LIBPDA_EXPLORER_HPP */ diff --git a/libpda/libpda/explorer.test.cpp b/libpda/libpda/explorer.test.cpp new file mode 100644 index 0000000..dc58502 --- /dev/null +++ b/libpda/libpda/explorer.test.cpp @@ -0,0 +1,177 @@ +/* libpda/explorer.test.cpp + * Unit-Tests fuer die Verzeichnisnavigation (doctest). + * + * Die Tests bauen sich einen eigenen Verzeichnisbaum im Build-Verzeichnis -- + * ein Test, der auf vorhandene Verzeichnisse des Rechners baut, schlaegt auf + * einer anderen Maschine fehl. + */ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include +#include + +#include + +namespace fs = std::filesystem; + +namespace +{ + +/* Angelegter Baum: + * + * root/ + * unterordner/ + * datei.txt 5 Bytes ("hallo") + * zweite.txt leer + * .versteckt versteckt (fuehrender Punkt) + */ +class TempTree +{ +public: + TempTree() : root{fs::current_path() / "explorer.test.tree"} + { + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "unterordner", ec); + + std::ofstream{root / "datei.txt"} << "hallo"; + /* Benannt, damit klar ist, dass hier eine LEERE Datei entstehen soll -- + * ein namenloses Temporary sieht wie ein vergessener Ausdruck aus. */ + const std::ofstream leer{root / "zweite.txt"}; + (void) leer; + std::ofstream{root / ".versteckt"} << "x"; + } + + ~TempTree() + { + std::error_code ec; + fs::remove_all(root, ec); + } + + TempTree(const TempTree&) = delete; + TempTree& + operator=(const TempTree&) = delete; + + const fs::path& + get() const noexcept + { + return root; + } + +private: + fs::path root; +}; + +} /* namespace */ + +TEST_CASE("list returns directories first, then files alphabetically") +{ + const TempTree tree; + const pda::Explorer explorer{tree.get()}; + + const auto entries = explorer.list(); + REQUIRE(entries.has_value()); + REQUIRE(entries->size() == 3U); + + CHECK((*entries)[0].name == "unterordner"); + CHECK((*entries)[0].is_directory); + CHECK((*entries)[1].name == "datei.txt"); + CHECK((*entries)[2].name == "zweite.txt"); +} + +TEST_CASE("hidden entries are skipped unless requested") +{ + const TempTree tree; + const pda::Explorer explorer{tree.get()}; + + CHECK(explorer.list(false)->size() == 3U); + CHECK(explorer.list(true)->size() == 4U); +} + +TEST_CASE("file sizes are reported, directories report zero") +{ + const TempTree tree; + const pda::Explorer explorer{tree.get()}; + + const auto entries = explorer.list(); + REQUIRE(entries.has_value()); + + CHECK((*entries)[0].size == 0U); /* unterordner */ + CHECK((*entries)[1].size == 5U); /* datei.txt = "hallo" */ + CHECK((*entries)[2].size == 0U); /* zweite.txt ist leer */ +} + +TEST_CASE("enter descends and up returns") +{ + const TempTree tree; + pda::Explorer explorer{tree.get()}; + + REQUIRE(explorer.enter("unterordner").has_value()); + CHECK(explorer.current().filename() == "unterordner"); + + explorer.up(); + CHECK(explorer.current().filename() == tree.get().filename()); +} + +TEST_CASE("entering a file or a missing name is an error") +{ + const TempTree tree; + pda::Explorer explorer{tree.get()}; + + const auto file = explorer.enter("datei.txt"); + REQUIRE_FALSE(file.has_value()); + CHECK(file.error() == pda::ExplorerError::not_a_directory); + + CHECK_FALSE(explorer.enter("gibtesnicht").has_value()); + + /* Nach einem gescheiterten Wechsel muss das Verzeichnis stehen bleiben. */ + CHECK(explorer.current().filename() == tree.get().filename()); +} + +TEST_CASE("enter accepts .. and - as shorthand for up") +{ + const TempTree tree; + pda::Explorer explorer{tree.get() / "unterordner"}; + + REQUIRE(explorer.enter("..").has_value()); + CHECK(explorer.current().filename() == tree.get().filename()); +} + +TEST_CASE("up stops at the filesystem root instead of looping") +{ + pda::Explorer explorer{fs::path{"/"}}; + + explorer.up(); + explorer.up(); + + CHECK(explorer.current() == fs::path{"/"}); +} + +TEST_CASE("listing a path that is not a directory reports an error") +{ + const TempTree tree; + const pda::Explorer explorer{tree.get() / "datei.txt"}; + + const auto entries = explorer.list(); + REQUIRE_FALSE(entries.has_value()); + CHECK(entries.error() == pda::ExplorerError::not_a_directory); +} + +TEST_CASE("human_size switches units at 1024") +{ + CHECK(pda::human_size(0U) == "0B"); + CHECK(pda::human_size(999U) == "999B"); + CHECK(pda::human_size(1023U) == "1023B"); + CHECK(pda::human_size(1024U) == "1.0K"); + CHECK(pda::human_size(1536U) == "1.5K"); + CHECK(pda::human_size(std::uintmax_t{1024} * 1024) == "1.0M"); + CHECK(pda::human_size(std::uintmax_t{3} * 1024 * 1024 * 1024) == "3.0G"); +} + +TEST_CASE("describe covers every error value") +{ + CHECK(pda::describe(pda::ExplorerError::not_a_directory) == "kein Verzeichnis"); + CHECK(pda::describe(pda::ExplorerError::not_accessible) == "nicht zugreifbar"); +} diff --git a/libpda/libpda/textfile.c b/libpda/libpda/textfile.c new file mode 100644 index 0000000..23911f5 --- /dev/null +++ b/libpda/libpda/textfile.c @@ -0,0 +1,252 @@ +/* libpda/textfile.c + * Implementierung von Datei-I/O und Escaping. + */ +#include +#include +#include + +#include + +const char* +textfile_status_text(TextfileStatus status) +{ + switch (status) + { + case TEXTFILE_OK: + return "ok"; + case TEXTFILE_ERR_OPEN: + return "Datei nicht zu oeffnen"; + case TEXTFILE_ERR_READ: + return "Lesefehler"; + case TEXTFILE_ERR_WRITE: + return "Schreibfehler"; + case TEXTFILE_ERR_MEMORY: + return "Speicher erschoepft"; + case TEXTFILE_ERR_TOO_BIG: + return "Datei zu gross"; + } + + /* Kein default im switch: so warnt der Compiler (-Wswitch), wenn ein + * neuer Enum-Wert dazukommt und hier vergessen wird. */ + return "unbekannter Fehler"; +} + +TextfileStatus +textfile_read(const char* path, TextBlob* out_blob) +{ + out_blob->data = NULL; + out_blob->size = 0U; + + FILE* file = fopen(path, "rb"); + if (!file) return TEXTFILE_ERR_OPEN; + + /* Groesse ueber fseek/ftell statt stat: das bleibt reines C ohne + * POSIX-Header und funktioniert auch auf Windows. */ + if (fseek(file, 0L, SEEK_END) != 0) + { + fclose(file); + return TEXTFILE_ERR_READ; + } + + const long end = ftell(file); + if (end < 0L) + { + fclose(file); + return TEXTFILE_ERR_READ; + } + + const unsigned long size = (unsigned long) end; + if (size > TEXTFILE_MAX_SIZE) + { + fclose(file); + return TEXTFILE_ERR_TOO_BIG; + } + + /* fseek statt rewind: rewind meldet Fehler ueberhaupt nicht, sondern + * setzt still errno. Wer den Rueckgabewert von fseek prueft, merkt es. */ + if (fseek(file, 0L, SEEK_SET) != 0) + { + fclose(file); + return TEXTFILE_ERR_READ; + } + + /* +1 fuer die abschliessende Null, die der Header zusichert. */ + /* Kapazitaet als eigene Variable: der Clamp weiter unten bezieht sich + * damit auf DIESELBE Groesse wie das malloc. Rechnet man dort erneut mit + * "size", muss jeder Leser (und jeder Static Analyzer) sich selbst + * ueberlegen, dass beide Ausdruecke denselben Wert haben. */ + const size_t capacity = (size_t) size + 1U; + + char* buffer = malloc(capacity); + if (!buffer) + { + fclose(file); + return TEXTFILE_ERR_MEMORY; + } + + size_t got = fread(buffer, 1U, capacity - 1U, file); + + /* fread kann laut Standard nicht mehr liefern als angefordert -- der + * Clamp ist also nie wirksam. Er steht hier, weil er die Invariante + * "got ist ein gueltiger Index in buffer" ueberpruefbar macht, statt sie + * nur zu behaupten. Kostet einen Vergleich pro Datei. */ + if (got >= capacity) got = capacity - 1U; + + /* ferror statt got != size pruefen: bei Textmodus-Dateien auf Windows + * liefert fread legitim weniger Bytes als die Dateigroesse. */ + if (ferror(file)) + { + free(buffer); + fclose(file); + return TEXTFILE_ERR_READ; + } + + fclose(file); + + /* Fehlalarm von clang-analyzer-security.ArrayBound (Unterdrueckung in der + * Zeile direkt ueber dem Zugriff -- NOLINTNEXTLINE wirkt nur dort). + * + * Der Beweis, dass der Zugriff gueltig ist: + * capacity = size + 1, und size ist oben auf TEXTFILE_MAX_SIZE begrenzt + * -> capacity >= 1, kein Ueberlauf. + * Nach dem Clamp in Zeile 93 gilt got <= capacity - 1. + * buffer zeigt auf capacity Bytes -> buffer[got] liegt darin. + * + * Der Analyzer nimmt den true-Zweig des Clamps, weist got = capacity - 1 + * zu und meldet den Zugriff danach TROTZDEM -- er traegt die Zuweisung + * nicht weiter. Der asan-ubsan-Preset laeuft ueber genau diesen Pfad + * (textfile.test.c liest leere und gefuellte Dateien) und schweigt. */ + /* NOLINTNEXTLINE(clang-analyzer-security.ArrayBound) */ + buffer[got] = '\0'; + out_blob->data = buffer; + out_blob->size = got; + return TEXTFILE_OK; +} + +TextfileStatus +textfile_write(const char* path, const char* data, size_t size) +{ + FILE* file = fopen(path, "wb"); + if (!file) return TEXTFILE_ERR_OPEN; + + if (size > 0U && fwrite(data, 1U, size, file) != size) + { + fclose(file); + return TEXTFILE_ERR_WRITE; + } + + /* fclose kann selbst fehlschlagen: gepufferte Daten landen erst hier auf + * der Platte. Wer nur fwrite prueft, meldet Erfolg fuer eine leere Datei. */ + if (fclose(file) != 0) return TEXTFILE_ERR_WRITE; + + return TEXTFILE_OK; +} + +void +textfile_blob_free(TextBlob* blob) +{ + if (!blob) return; + + free(blob->data); + blob->data = NULL; + blob->size = 0U; +} + +/* ---- Escaping ---- */ + +char* +textfile_escape(const char* field) +{ + const size_t length = strlen(field); + + /* Schlimmster Fall: jedes Zeichen wird zu zweien. Einmal grosszuegig + * allokieren ist hier billiger als mitzuzaehlen und nachzuwachsen. */ + char* out = malloc((length * 2U) + 1U); + if (!out) return NULL; + + size_t w = 0U; + for (size_t r = 0U; r < length; r++) + { + switch (field[r]) + { + case '\\': + out[w++] = '\\'; + out[w++] = '\\'; + break; + case '\t': + out[w++] = '\\'; + out[w++] = 't'; + break; + case '\n': + out[w++] = '\\'; + out[w++] = 'n'; + break; + case '\r': + out[w++] = '\\'; + out[w++] = 'r'; + break; + default: + out[w++] = field[r]; + break; + } + } + + out[w] = '\0'; + return out; +} + +char* +textfile_unescape(const char* field) +{ + const size_t length = strlen(field); + + /* Entschluesseln kann nur kuerzer werden, nie laenger. */ + char* out = malloc(length + 1U); + if (!out) return NULL; + + size_t w = 0U; + for (size_t r = 0U; r < length; r++) + { + if (field[r] != '\\') + { + out[w++] = field[r]; + continue; + } + + /* Backslash als letztes Zeichen: unvollstaendige Sequenz, verwerfen. */ + if (r + 1U >= length) break; + + r++; + switch (field[r]) + { + case 't': + out[w++] = '\t'; + break; + case 'n': + out[w++] = '\n'; + break; + case 'r': + out[w++] = '\r'; + break; + case '\\': + out[w++] = '\\'; + break; + + /* Unbekannte Sequenz: beide Zeichen unveraendert uebernehmen, statt + * still etwas zu verschlucken. */ + default: + out[w++] = '\\'; + out[w++] = field[r]; + break; + } + } + + out[w] = '\0'; + return out; +} + +void +textfile_free_string(char* text) +{ + free(text); +} diff --git a/libpda/libpda/textfile.h b/libpda/libpda/textfile.h new file mode 100644 index 0000000..4a6c0f2 --- /dev/null +++ b/libpda/libpda/textfile.h @@ -0,0 +1,109 @@ +/* libpda/textfile.h + * Datei-Ein-/Ausgabe und Feld-Escaping (C). + * + * Warum C und nicht C++: das hier ist die Schicht, die mit rohen Puffern, + * Groessen und Besitzverhaeltnissen arbeitet. In C muss jede Freigabe + * hingeschrieben werden, und genau das macht die Regeln sichtbar. Die + * C++-Schicht darueber (contact.hpp) verpackt das in RAII. + * + * BESITZ: Jede Funktion, die einen char* zurueckgibt, uebergibt den Besitz an + * den Aufrufer. Freigabe mit textfile_free_string(). TextBlob wird mit + * textfile_blob_free() freigegeben. Es gibt keine Ausnahme von dieser Regel. + */ +#ifndef LIBPDA_TEXTFILE_H +#define LIBPDA_TEXTFILE_H + +#include + +/* Definiert PDA_EXPORT. Die Datei wird von CMake erzeugt + * (generate_export_header) und liegt im Build-Baum bzw. nach dem + * Installieren neben diesem Header. */ +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +/* ---- Fehlercodes ---- */ + +/* Bewusst eigene Codes statt errno: der Aufrufer soll nicht raten muessen, ob + * errno noch zu diesem Aufruf gehoert. */ +typedef enum +{ + TEXTFILE_OK = 0, + TEXTFILE_ERR_OPEN, /* Datei nicht zu oeffnen */ + TEXTFILE_ERR_READ, /* Lesefehler mitten in der Datei */ + TEXTFILE_ERR_WRITE, /* Schreibfehler, Datei evtl. unvollstaendig */ + TEXTFILE_ERR_MEMORY, /* malloc fehlgeschlagen */ + TEXTFILE_ERR_TOO_BIG /* Datei groesser als TEXTFILE_MAX_SIZE */ +} TextfileStatus; + +/* Obergrenze fuer textfile_read. Ein PDA laedt Notizen, keine Datenbanken -- + * ohne Grenze wuerde ein versehentliches "edit /dev/zero" den Rechner fuellen. */ +/* UL, nicht u: die Multiplikation selbst muss schon breit genug rechnen. + * Mit (16u * 1024u * 1024u) liefe sie in unsigned int und wuerde erst + * DANACH geweitet -- auf einer Plattform mit 16-Bit-int ein Ueberlauf. */ +#define TEXTFILE_MAX_SIZE (16UL * 1024UL * 1024UL) + +/* Menschenlesbarer Text zu einem Statuscode. Zeigt auf statischen Speicher, + * darf NICHT freigegeben werden. */ +PDA_EXPORT const char* +textfile_status_text(TextfileStatus status); + +/* ---- Dateiinhalt als Block ---- */ + +/* data ist immer nullterminiert (data[size] == '\0'), damit der Block direkt + * an C-String-Funktionen weitergereicht werden kann. size zaehlt die + * Nutzbytes OHNE die Null. */ +typedef struct +{ + char* data; + size_t size; +} TextBlob; + +/* Liest die ganze Datei. Bei Erfolg gehoert out_blob dem Aufrufer. + * Im Fehlerfall wird out_blob auf {NULL, 0} gesetzt. */ +PDA_EXPORT TextfileStatus +textfile_read(const char* path, TextBlob* out_blob); + +/* Schreibt size Bytes nach path und ersetzt vorhandenen Inhalt. */ +PDA_EXPORT TextfileStatus +textfile_write(const char* path, const char* data, size_t size); + +/* Gibt den Block frei und setzt ihn auf {NULL, 0}. Mit blob == NULL oder + * bereits freigegebenem Block ist der Aufruf gefahrlos. */ +PDA_EXPORT void +textfile_blob_free(TextBlob* blob); + +/* ---- Feld-Escaping ---- */ + +/* + * Das Satzformat ist eine Zeile pro Datensatz, Felder durch TAB getrennt. + * Damit ein Feld selbst TAB oder Zeilenumbruch enthalten darf, werden diese + * Zeichen ersetzt: + * + * \ -> \\ TAB -> \t LF -> \n CR -> \r + * + * Der Backslash muss zuerst behandelt werden -- sonst wuerde ein escapetes + * \t beim naechsten Durchgang erneut escapet. + */ + +/* Gibt eine neue, escapete Zeichenkette zurueck. NULL bei Speichermangel. */ +PDA_EXPORT char* +textfile_escape(const char* field); + +/* Umkehrung. Ein Backslash am Zeilenende ohne Folgezeichen wird verworfen. + * NULL bei Speichermangel. */ +PDA_EXPORT char* +textfile_unescape(const char* field); + +/* Gibt eine Zeichenkette aus textfile_escape/textfile_unescape frei. */ +PDA_EXPORT void +textfile_free_string(char* text); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* LIBPDA_TEXTFILE_H */ diff --git a/libpda/libpda/textfile.test.c b/libpda/libpda/textfile.test.c new file mode 100644 index 0000000..2673faa --- /dev/null +++ b/libpda/libpda/textfile.test.c @@ -0,0 +1,178 @@ +/* libpda/textfile.test.c + * Unit-Tests fuer textfile (Unity). + */ +#include + +#include +#include + +#include + +/* Testdateien landen im Build-Verzeichnis, weil CTest die Executable dort + * startet -- das Quellverzeichnis bleibt sauber. */ +static const char* const test_path = "textfile.test.tmp"; + +void +setUp(void) +{ +} + +void +tearDown(void) +{ + remove(test_path); +} + +/* ---- Escaping ---- */ + +static void +test_escape_leaves_plain_text_alone(void) +{ + char* escaped = textfile_escape("Anna Schmidt"); + TEST_ASSERT_NOT_NULL(escaped); + TEST_ASSERT_EQUAL_STRING("Anna Schmidt", escaped); + textfile_free_string(escaped); +} + +static void +test_escape_replaces_tab_newline_and_backslash(void) +{ + char* escaped = textfile_escape("a\tb\nc\\d\re"); + TEST_ASSERT_NOT_NULL(escaped); + TEST_ASSERT_EQUAL_STRING("a\\tb\\nc\\\\d\\re", escaped); + textfile_free_string(escaped); +} + +static void +test_unescape_reverses_escape(void) +{ + const char* original = "Zeile1\nZeile2\tSpalte\\Ende"; + + char* escaped = textfile_escape(original); + TEST_ASSERT_NOT_NULL(escaped); + + /* Entscheidend: im escapeten Text darf kein echter Umbruch mehr stehen, + * sonst zerfaellt ein Datensatz beim Zeilenweise-Lesen. */ + TEST_ASSERT_NULL(strchr(escaped, '\n')); + TEST_ASSERT_NULL(strchr(escaped, '\t')); + + char* back = textfile_unescape(escaped); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_EQUAL_STRING(original, back); + + textfile_free_string(escaped); + textfile_free_string(back); +} + +static void +test_unescape_keeps_unknown_sequence_verbatim(void) +{ + char* back = textfile_unescape("a\\qb"); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_EQUAL_STRING("a\\qb", back); + textfile_free_string(back); +} + +static void +test_unescape_drops_trailing_lone_backslash(void) +{ + char* back = textfile_unescape("abc\\"); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_EQUAL_STRING("abc", back); + textfile_free_string(back); +} + +static void +test_escape_handles_empty_string(void) +{ + char* escaped = textfile_escape(""); + TEST_ASSERT_NOT_NULL(escaped); + TEST_ASSERT_EQUAL_STRING("", escaped); + textfile_free_string(escaped); +} + +/* ---- Datei-I/O ---- */ + +static void +test_write_then_read_roundtrip(void) +{ + const char* payload = "erste Zeile\nzweite Zeile\n"; + const size_t length = strlen(payload); + + TEST_ASSERT_EQUAL_INT(TEXTFILE_OK, textfile_write(test_path, payload, length)); + + TextBlob blob; + TEST_ASSERT_EQUAL_INT(TEXTFILE_OK, textfile_read(test_path, &blob)); + TEST_ASSERT_EQUAL_size_t(length, blob.size); + TEST_ASSERT_EQUAL_STRING(payload, blob.data); + + /* Der Header sichert die Nullterminierung zu -- darauf verlaesst sich die + * ganze C++-Schicht darueber. */ + TEST_ASSERT_EQUAL_CHAR('\0', blob.data[blob.size]); + + textfile_blob_free(&blob); + TEST_ASSERT_NULL(blob.data); + TEST_ASSERT_EQUAL_size_t(0U, blob.size); +} + +static void +test_write_empty_file_is_readable(void) +{ + TEST_ASSERT_EQUAL_INT(TEXTFILE_OK, textfile_write(test_path, "", 0U)); + + TextBlob blob; + TEST_ASSERT_EQUAL_INT(TEXTFILE_OK, textfile_read(test_path, &blob)); + TEST_ASSERT_EQUAL_size_t(0U, blob.size); + TEST_ASSERT_NOT_NULL(blob.data); + TEST_ASSERT_EQUAL_STRING("", blob.data); + + textfile_blob_free(&blob); +} + +static void +test_read_missing_file_reports_open_error(void) +{ + TextBlob blob; + TEST_ASSERT_EQUAL_INT(TEXTFILE_ERR_OPEN, textfile_read("gibt/es/nicht.txt", &blob)); + + /* Im Fehlerfall muss der Blob leer sein, sonst wuerde der Aufrufer einen + * uninitialisierten Zeiger freigeben. */ + TEST_ASSERT_NULL(blob.data); + TEST_ASSERT_EQUAL_size_t(0U, blob.size); +} + +static void +test_blob_free_is_safe_twice(void) +{ + TextBlob blob = {NULL, 0U}; + textfile_blob_free(&blob); + textfile_blob_free(&blob); + textfile_blob_free(NULL); + TEST_ASSERT_NULL(blob.data); +} + +static void +test_status_text_is_never_null(void) +{ + TEST_ASSERT_EQUAL_STRING("ok", textfile_status_text(TEXTFILE_OK)); + TEST_ASSERT_NOT_NULL(textfile_status_text(TEXTFILE_ERR_OPEN)); + TEST_ASSERT_NOT_NULL(textfile_status_text(TEXTFILE_ERR_TOO_BIG)); +} + +int +main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_escape_leaves_plain_text_alone); + RUN_TEST(test_escape_replaces_tab_newline_and_backslash); + RUN_TEST(test_unescape_reverses_escape); + RUN_TEST(test_unescape_keeps_unknown_sequence_verbatim); + RUN_TEST(test_unescape_drops_trailing_lone_backslash); + RUN_TEST(test_escape_handles_empty_string); + RUN_TEST(test_write_then_read_roundtrip); + RUN_TEST(test_write_empty_file_is_readable); + RUN_TEST(test_read_missing_file_reports_open_error); + RUN_TEST(test_blob_free_is_safe_twice); + RUN_TEST(test_status_text_is_never_null); + return UNITY_END(); +} diff --git a/tests/CMakeLists.txt b/libpda/tests/CMakeLists.txt similarity index 51% rename from tests/CMakeLists.txt rename to libpda/tests/CMakeLists.txt index b90b81a..b6b29ad 100644 --- a/tests/CMakeLists.txt +++ b/libpda/tests/CMakeLists.txt @@ -1,15 +1,16 @@ # --------------------------------------------------------------------------- -# Integrationstests (P1204R0 Regel 7.2). +# Integrationstests der Bibliothek (P1204R0 Regel 7.2). # -# Unterschied zu den Unit-Tests neben den Modulen: diese hier benutzen -# ausschliesslich die OEFFENTLICHE API -- also und sonst -# nichts. Damit testen sie genau das, was ein Benutzer bekommt, und koennten -# im Prinzip auch gegen eine installierte Version laufen. +# Unterschied zu den Unit-Tests neben den Modulen: diese benutzen +# ausschliesslich die OEFFENTLICHE API -- und sonst nichts. Kein +# doctest, kein Unity, kein Blick in details/. # -# Jedes Unterverzeichnis ist ein eigener Testfall mit eigener driver.c/.cpp. +# Der Grund fuer das eigene Verzeichnis steht in P1204R0: solche Tests sollen +# auch gegen eine INSTALLIERTE Bibliothek laufen koennen. Was hier gruen ist, +# ist gruen fuer einen Benutzer. # --------------------------------------------------------------------------- -function(playground_add_integration_test name) +function(libpda_add_integration_test name) set(target "integration_${name}") if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${name}/driver.c") @@ -21,8 +22,8 @@ function(playground_add_integration_test name) return() endif() - target_link_libraries(${target} PRIVATE playground_core playground_warnings) + target_link_libraries(${target} PRIVATE pda::pda pda_warnings) add_test(NAME "integration.${name}" COMMAND ${target}) endfunction() -playground_add_integration_test(basics) +libpda_add_integration_test(basics) diff --git a/libpda/tests/basics/driver.cpp b/libpda/tests/basics/driver.cpp new file mode 100644 index 0000000..0f598ea --- /dev/null +++ b/libpda/tests/basics/driver.cpp @@ -0,0 +1,156 @@ +/* tests/basics/driver.cpp + * Integrationstest der Bibliothek (P1204R0 Regel 7.2). + * + * Kein Test-Framework: ein Integrationstest soll ein eigenstaendiges Programm + * sein, das ohne Argumente laeuft und ueber seinen Exit-Code Auskunft gibt. + * Er benutzt nur -- genau das, was ein Benutzer bekommt. + * + * Der Test bildet einen kleinen echten Ablauf ab: Kontakte anlegen, sichern, + * in einem zweiten Buch laden, Notizen schreiben und ueber den Explorer + * nachsehen, dass die Dateien wirklich da sind. + */ +#include +#include +#include +#include + +#include +#include +#include +#include + +#define CHECK(cond) \ + do \ + { \ + if (!(cond)) \ + { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + return 1; \ + } \ + } while (0) + +namespace fs = std::filesystem; + +namespace +{ + +int +contacts_survive_a_save_and_load_cycle(const fs::path& dir) +{ + const fs::path file = dir / "kontakte.tsv"; + + pda::ContactBook book; + CHECK(book.add(pda::Contact{"Anna Schmidt", "0151", "anna@example.org", "Schwester"})); + CHECK(book.add(pda::Contact{"Bert Meier", "0170", "bert@firma.de", "Kollege\tAbt. 4"})); + CHECK(book.size() == 2U); + + CHECK(book.save(file).has_value()); + + pda::ContactBook reloaded; + CHECK(reloaded.load(file).has_value()); + CHECK(reloaded.size() == 2U); + + const pda::Contact* bert = reloaded.find("Bert Meier"); + CHECK(bert != nullptr); + + /* Das TAB in der Notiz ist der Grund fuer das Escaping in textfile.c -- + * ohne das haette der Datensatz beim Laden fuenf Felder statt vier. */ + CHECK(bert->note == "Kollege\tAbt. 4"); + + return 0; +} + +int +notes_round_trip_through_a_file(const fs::path& dir) +{ + const fs::path file = dir / "notizen.txt"; + + pda::TextBuffer buffer; + buffer.append("Einkaufen"); + buffer.append("Zahnarzt anrufen"); + CHECK(buffer.modified()); + + CHECK(buffer.save(file).has_value()); + CHECK(!buffer.modified()); + + pda::TextBuffer reloaded; + CHECK(reloaded.load(file).has_value()); + CHECK(reloaded.line_count() == 2U); + CHECK(*reloaded.line(2U) == "Zahnarzt anrufen"); + + return 0; +} + +int +the_explorer_sees_what_we_wrote(const fs::path& dir) +{ + const pda::Explorer explorer{dir}; + + const auto entries = explorer.list(); + CHECK(entries.has_value()); + CHECK(entries->size() == 2U); + + /* Alphabetisch: kontakte.tsv vor notizen.txt. */ + CHECK((*entries)[0].name == "kontakte.tsv"); + CHECK((*entries)[1].name == "notizen.txt"); + CHECK((*entries)[0].size > 0U); + + return 0; +} + +int +the_calculator_agrees_with_arithmetic() +{ + const auto value = pda::evaluate("(2 + 3) * 4 - 10 / 2"); + CHECK(value.has_value()); + CHECK(*value == 15.0); + + const auto broken = pda::evaluate("2 +"); + CHECK(!broken.has_value()); + CHECK(!pda::format_error(broken.error()).empty()); + + return 0; +} + +} /* namespace */ + +int +main() +{ + /* main darf nichts nach aussen werfen: eine entwichene Exception waere + * ein std::terminate, und CTest zeigte nur "Subprocess aborted" ohne + * jeden Hinweis, welcher Fall gescheitert ist. */ + try + { + /* Eigenes Arbeitsverzeichnis, damit der Test nichts findet, was er nicht + * selbst angelegt hat -- und nichts hinterlaesst. */ + const fs::path dir = fs::current_path() / "integration.basics.tmp"; + + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir, ec); + if (ec) + { + std::fprintf(stderr, "FAIL: Arbeitsverzeichnis nicht anzulegen\n"); + return 1; + } + + int failures = 0; + failures += contacts_survive_a_save_and_load_cycle(dir); + failures += notes_round_trip_through_a_file(dir); + failures += the_explorer_sees_what_we_wrote(dir); + failures += the_calculator_agrees_with_arithmetic(); + + fs::remove_all(dir, ec); + + if (failures != 0) return 1; + + std::puts("integration/basics: ok"); + return 0; + } + catch (const std::exception& error) + { + std::fprintf(stderr, "FAIL: unerwartete Exception: %s\n", error.what()); + return 1; + } +} diff --git a/pda/CMakeLists.txt b/pda/CMakeLists.txt new file mode 100644 index 0000000..92db450 --- /dev/null +++ b/pda/CMakeLists.txt @@ -0,0 +1,104 @@ +# --------------------------------------------------------------------------- +# pda - die Anwendung +# +# Eigenstaendiges Projekt (P1204R0), genau wie libpda. Der entscheidende +# Unterschied steht im else-Zweig unten: allein gebaut sucht die Anwendung +# libpda ueber find_package -- also genau so, wie ein FREMDES Projekt sie +# benutzen wuerde. +# +# Damit ist dieses CMakeLists.txt gleichzeitig der Beweis, dass der +# Export-Mechanismus in libpda/CMakeLists.txt funktioniert. +# --------------------------------------------------------------------------- +cmake_minimum_required(VERSION 3.28) + +project(pda + VERSION 0.1.0 + DESCRIPTION "PDA: Kontakte, Rechner, Notizen, Dateien" + LANGUAGES CXX) + +if(PROJECT_IS_TOP_LEVEL) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../cmake") + + include(ProjectDefaults) + include(Warnings) + include(Sanitizers) + + option(PDA_BUILD_TESTS "Tests bauen" ON) + + # Allein gebaut: libpda muss installiert und ueber CMAKE_PREFIX_PATH + # auffindbar sein. + # + # cmake -S pda -B build/nur-app -DCMAKE_PREFIX_PATH=/pfad/zur/installation + # + # REQUIRED sorgt fuer einen sofortigen, verstaendlichen Abbruch statt fuer + # einen Linkerfehler dreissig Zeilen spaeter. + find_package(pda 0.1 REQUIRED) + + if(PDA_BUILD_TESTS) + enable_testing() + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../third_party" third_party) + endif() +endif() + +# --------------------------------------------------------------------------- +# Die Logik als interne Bibliothek. +# +# Warum nicht einfach alles in die Executable: ein Unit-Test muesste dann +# shell.cpp erneut uebersetzen UND wuerde main.cpp mitziehen -- zwei +# main()-Funktionen in einer Executable sind ein Linkerfehler. +# +# Das Muster dagegen ist immer dasselbe und gilt weit ueber CMake hinaus: +# main.cpp bleibt duenn und macht nur Ein-/Ausgabe, alles Testbare wandert in +# eine Bibliothek. Hier ist sie STATIC und wird nicht installiert -- sie ist +# ein Bauhilfsmittel, kein Teil der Auslieferung. +# --------------------------------------------------------------------------- +add_library(pda_shell STATIC + pda/shell.cpp + pda/shell.hpp +) + +# Der Include-Root ist das PROJEKTWURZELVERZEICHNIS (also pda/), nicht +# pda/pda. Nur so loest auf pda/pda/shell.hpp auf. +# +# PUBLIC, weil sowohl die Executable als auch der Test +# inkludieren -- beide erben den Pfad, statt ihn zu wiederholen. +target_include_directories(pda_shell PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") + +# PUBLIC bei pda::pda ist kein Versehen: shell.hpp inkludiert +# im INTERFACE. Wer pda_shell linkt, braucht die Header +# von libpda also ebenfalls. Waere das PRIVATE, wuerde shell.test.cpp mit +# "libpda/contact.hpp: No such file" scheitern. +target_link_libraries(pda_shell PUBLIC pda::pda) +target_link_libraries(pda_shell PRIVATE pda_warnings) + +# --------------------------------------------------------------------------- +# Die Executable +# +# Targetname "pda_app", nicht "pda": im Superprojekt gibt es bereits ein +# Bibliothekstarget "pda", und zwei Targets duerfen nicht gleich heissen. +# Der DATEIname wird ueber OUTPUT_NAME trotzdem "pda" -- Targetname und +# Dateiname sind in CMake zwei verschiedene Dinge. +# --------------------------------------------------------------------------- +add_executable(pda_app pda/main.cpp) +set_target_properties(pda_app PROPERTIES OUTPUT_NAME pda) + +target_link_libraries(pda_app PRIVATE pda_shell pda_warnings) + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +if(PDA_BUILD_TESTS) + if(TARGET doctest) + add_executable(shell.test pda/shell.test.cpp) + target_link_libraries(shell.test PRIVATE pda_shell doctest pda_warnings) + doctest_discover_tests(shell.test TEST_PREFIX "doctest.") + endif() + + add_subdirectory(tests) +endif() + +# --------------------------------------------------------------------------- +# Installation. Nur die Executable -- eine Anwendung exportiert keine Targets. +# --------------------------------------------------------------------------- +include(GNUInstallDirs) +install(TARGETS pda_app RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/pda/pda/main.cpp b/pda/pda/main.cpp new file mode 100644 index 0000000..d8ebddc --- /dev/null +++ b/pda/pda/main.cpp @@ -0,0 +1,128 @@ +/* pda/main.cpp + * Einstiegspunkt der Anwendung. + * + * Alles, was hier steht, hat mit Ein-/Ausgabe zu tun -- die gesamte Logik + * liegt in Shell und darunter in libpda. Das ist die Trennung, die das + * Projekt testbar macht: main() selbst wird nie getestet, also darf hier + * nichts stehen, was schiefgehen kann. + * + * Zwei Betriebsarten: + * pda interaktiv, liest von stdin + * pda fuehrt genau ein Kommando aus und beendet sich + * + * Die zweite Form macht das Programm skriptfaehig: + * pda calc "2^10" -> 1024 + */ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ + +/* Baut aus argv eine einzige Kommandozeile. Argumente mit Leerzeichen werden + * wieder in Anfuehrungszeichen gesetzt, damit der Tokenizer der Shell sie + * genauso sieht, wie die Shell des Systems sie uebergeben hat. */ +[[nodiscard]] std::string +join_arguments(int argc, char** argv) +{ + std::string line; + + for (int i = 1; i < argc; i++) + { + if (i > 1) line += ' '; + + const std::string_view argument{argv[i]}; + if (argument.contains(' ')) + { + line += '"'; + line += argument; + line += '"'; + } + else + { + line += argument; + } + } + + return line; +} + +void +emit(const pda::CommandResult& result) +{ + if (result.output.empty()) return; + + /* Fehler nach stderr, damit "pda contact list > datei" im Fehlerfall + * keine Fehlermeldung in die Datei schreibt. */ + std::ostream& stream = result.failed ? std::cerr : std::cout; + stream << result.output; + + if (!result.output.ends_with('\n')) stream << '\n'; +} + +int +run_batch(pda::Shell& shell, const std::string& line) +{ + const pda::CommandResult result = shell.execute(line); + emit(result); + return result.failed ? 1 : 0; +} + +int +run_interactive(pda::Shell& shell) +{ + std::println("pda {} -- 'help' zeigt die Kommandos, 'quit' beendet.", + pda::details::version_string); + + std::string line; + for (;;) + { + std::cout << shell.prompt() << std::flush; + + /* getline schlaegt bei EOF fehl -- das ist Strg-D und ein normales + * Ende, kein Fehler. */ + if (!std::getline(std::cin, line)) break; + + const pda::CommandResult result = shell.execute(line); + if (result.quit) break; + + emit(result); + } + + return 0; +} + +} /* namespace */ + +int +main(int argc, char** argv) +{ + /* main darf nichts nach aussen werfen: eine entwichene Exception waere + * ein std::terminate ohne verwertbare Meldung. */ + try + { + pda::Shell shell{std::filesystem::current_path()}; + + if (argc > 1) return run_batch(shell, join_arguments(argc, argv)); + + return run_interactive(shell); + } + catch (const std::exception& error) + { + std::cerr << "pda: " << error.what() << '\n'; + return 1; + } + catch (...) + { + std::cerr << "pda: unbekannter Fehler\n"; + return 1; + } +} diff --git a/pda/pda/shell.cpp b/pda/pda/shell.cpp new file mode 100644 index 0000000..c18990e --- /dev/null +++ b/pda/pda/shell.cpp @@ -0,0 +1,384 @@ +/* pda/shell.cpp + * Implementierung der Kommandoverarbeitung. + */ +#include +#include + +#include +#include +#include + +namespace pda +{ +namespace +{ + +[[nodiscard]] CommandResult +ok(std::string text) +{ + return CommandResult{std::move(text), false, false}; +} + +[[nodiscard]] CommandResult +error(std::string text) +{ + return CommandResult{std::move(text), true, false}; +} + +/* Haengt args[from..] mit Leerzeichen wieder zusammen -- fuer Kommandos, deren + * letztes Argument freier Text ist (note add, contact add ). */ +[[nodiscard]] std::string +join_from(const std::vector& args, std::size_t from) +{ + std::string out; + for (std::size_t i = from; i < args.size(); i++) + { + if (i > from) out += ' '; + out += args[i]; + } + + return out; +} + +[[nodiscard]] std::string +usage(std::string_view command) +{ + return std::format("Aufruf: {}", command); +} + +/* Rechnen liest keinen Shell-Zustand -- deshalb freie Funktion und kein + * Member. Steht vor execute(), weil C++ Deklaration vor Benutzung will. */ +CommandResult +do_calc(const std::vector& args) +{ + if (args.size() < 2U) return error(usage("calc ")); + + /* Wieder zusammensetzen: der Tokenizer hat an Leerzeichen getrennt, aber + * "1 + 2" ist EIN Ausdruck. */ + const std::string expression = join_from(args, 1U); + const auto value = evaluate(expression); + + if (!value) return error(format_error(value.error())); + + /* {:g} statt {}: 4 statt 4.0, aber 1.5 bleibt 1.5. */ + return ok(std::format("{:g}", *value)); +} + +} /* namespace */ + +std::vector +tokenize(std::string_view line) +{ + std::vector tokens; + std::string current; + bool in_quotes = false; + bool has_token = false; + + for (std::size_t i = 0U; i < line.size(); i++) + { + const char c = line[i]; + + if (c == '"') + { + /* Ein leeres Argument "" muss erhalten bleiben, sonst kann man + * kein leeres Feld angeben. has_token merkt sich das. */ + in_quotes = !in_quotes; + has_token = true; + continue; + } + + if (!in_quotes && (c == ' ' || c == '\t')) + { + if (has_token) + { + tokens.push_back(current); + current.clear(); + has_token = false; + } + continue; + } + + current.push_back(c); + has_token = true; + } + + if (has_token) tokens.push_back(current); + + return tokens; +} + +std::string_view +help_text() noexcept +{ + return R"(Kommandos: + + calc rechnet, z.B. calc (2+3)*4 oder calc 2^10 + contact add [notiz] + contact list + contact find sucht in allen Feldern + contact del + contact save + contact load + note list Zeilen des Notizpuffers + note add + note del + note save + note load + ls [-a] Verzeichnis auflisten (-a auch versteckte) + cd ".." geht nach oben + pwd + version + help + quit + +Argumente mit Leerzeichen in Anfuehrungszeichen setzen: + contact add "Anna Schmidt" 0151 anna@example.org "meine Schwester" +)"; +} + +Shell::Shell(std::filesystem::path start_directory) : explorer{std::move(start_directory)} {} + +std::string +Shell::prompt() const +{ + return std::format("{}> ", explorer.current().filename().string()); +} + +const ContactBook& +Shell::contacts() const noexcept +{ + return book; +} + +const TextBuffer& +Shell::notes() const noexcept +{ + return buffer; +} + +const Explorer& +Shell::files() const noexcept +{ + return explorer; +} + +CommandResult +Shell::execute(std::string_view line) +{ + const std::vector args = tokenize(line); + + if (args.empty() || args[0].starts_with('#')) return ok(""); + + const std::string& command = args[0]; + + if (command == "quit" || command == "exit") return CommandResult{"", false, true}; + if (command == "help") return ok(std::string{help_text()}); + if (command == "version") return ok(std::format("pda {}", details::version_string)); + if (command == "calc") return do_calc(args); + if (command == "contact") return do_contact(args); + if (command == "note") return do_note(args); + if (command == "ls" || command == "cd" || command == "pwd") return do_files(args); + + return error(std::format("unbekanntes Kommando '{}' -- 'help' zeigt die Liste", command)); +} + +CommandResult +Shell::do_contact(const std::vector& args) +{ + if (args.size() < 2U) return error(usage("contact ...")); + + const std::string& sub = args[1]; + + if (sub == "add") + { + if (args.size() < 5U) return error(usage("contact add [notiz]")); + + Contact contact; + contact.name = args[2]; + contact.phone = args[3]; + contact.email = args[4]; + contact.note = args.size() > 5U ? join_from(args, 5U) : ""; + + if (!book.add(std::move(contact))) + { + return error(std::format("'{}' gibt es schon", args[2])); + } + + return ok(std::format("'{}' angelegt ({} Kontakte)", args[2], book.size())); + } + + if (sub == "list") + { + if (book.empty()) return ok("keine Kontakte"); + + std::string out; + for (const Contact& contact : book.all()) + { + out += std::format("{:<20} {:<16} {}\n", contact.name, contact.phone, contact.email); + } + + return ok(out); + } + + if (sub == "find") + { + if (args.size() < 3U) return error(usage("contact find ")); + + const std::vector hits = book.search(join_from(args, 2U)); + if (hits.empty()) return ok("nichts gefunden"); + + std::string out; + for (const Contact& contact : hits) + { + out += std::format("{:<20} {:<16} {:<28} {}\n", contact.name, contact.phone, + contact.email, contact.note); + } + + return ok(out); + } + + if (sub == "del") + { + if (args.size() < 3U) return error(usage("contact del ")); + + if (!book.remove(args[2])) return error(std::format("'{}' nicht gefunden", args[2])); + + return ok(std::format("'{}' geloescht", args[2])); + } + + if (sub == "save") + { + if (args.size() < 3U) return error(usage("contact save ")); + + const auto result = book.save(args[2]); + if (!result) return error(std::string{describe(result.error())}); + + return ok(std::format("{} Kontakte nach {} geschrieben", book.size(), args[2])); + } + + if (sub == "load") + { + if (args.size() < 3U) return error(usage("contact load ")); + + const auto result = book.load(args[2]); + if (!result) return error(std::string{describe(result.error())}); + + return ok(std::format("{} Kontakte aus {} geladen", book.size(), args[2])); + } + + return error(std::format("unbekanntes Unterkommando 'contact {}'", sub)); +} + +CommandResult +Shell::do_note(const std::vector& args) +{ + if (args.size() < 2U) return error(usage("note ...")); + + const std::string& sub = args[1]; + + if (sub == "add") + { + if (args.size() < 3U) return error(usage("note add ")); + + buffer.append(join_from(args, 2U)); + return ok(std::format("Zeile {} angelegt", buffer.line_count())); + } + + if (sub == "list") + { + if (buffer.empty()) return ok("Notizpuffer leer"); + + std::string out; + std::size_t number = 1U; + for (const std::string& row : buffer.lines()) + { + out += std::format("{:>4} {}\n", number++, row); + } + + if (buffer.modified()) out += "(ungespeicherte Aenderungen)\n"; + + return ok(out); + } + + if (sub == "del") + { + if (args.size() < 3U) return error(usage("note del ")); + + /* Bewusst kein std::stoul: eine Eingabe wie "abc" waere dort eine + * Exception mitten im Kommando. from_chars gibt es nicht her, also + * von Hand pruefen. */ + std::size_t number = 0U; + for (const char c : args[2]) + { + if (c < '0' || c > '9') return error(std::format("'{}' ist keine Zahl", args[2])); + + number = (number * 10U) + static_cast(c - '0'); + } + + const auto result = buffer.erase(number); + if (!result) return error(std::string{describe(result.error())}); + + return ok(std::format("Zeile {} geloescht", number)); + } + + if (sub == "save") + { + if (args.size() < 3U) return error(usage("note save ")); + + const auto result = buffer.save(args[2]); + if (!result) return error(std::string{describe(result.error())}); + + return ok(std::format("{} Zeilen nach {} geschrieben", buffer.line_count(), args[2])); + } + + if (sub == "load") + { + if (args.size() < 3U) return error(usage("note load ")); + + const auto result = buffer.load(args[2]); + if (!result) return error(std::string{describe(result.error())}); + + return ok(std::format("{} Zeilen aus {} geladen", buffer.line_count(), args[2])); + } + + return error(std::format("unbekanntes Unterkommando 'note {}'", sub)); +} + +CommandResult +Shell::do_files(const std::vector& args) +{ + const std::string& command = args[0]; + + if (command == "pwd") return ok(explorer.current().string()); + + if (command == "cd") + { + if (args.size() < 2U) return error(usage("cd ")); + + const auto result = explorer.enter(args[1]); + if (!result) + { + return error(std::format("{}: {}", args[1], describe(result.error()))); + } + + return ok(explorer.current().string()); + } + + /* ls */ + const bool show_hidden = args.size() > 1U && args[1] == "-a"; + const auto entries = explorer.list(show_hidden); + + if (!entries) return error(std::string{describe(entries.error())}); + if (entries->empty()) return ok("(leer)"); + + std::string out; + for (const DirEntry& entry : *entries) + { + out += entry.is_directory ? std::format("{:>8} {}/\n", "-", entry.name) + : std::format("{:>8} {}\n", human_size(entry.size), entry.name); + } + + return ok(out); +} + +} /* namespace pda */ diff --git a/pda/pda/shell.hpp b/pda/pda/shell.hpp new file mode 100644 index 0000000..3a37cf5 --- /dev/null +++ b/pda/pda/shell.hpp @@ -0,0 +1,102 @@ +/* pda/shell.hpp + * Kommandoverarbeitung des PDA. + * + * Der Namespace heisst pda -- genau wie in der Bibliothek. Das ist Absicht + * und steht so in P1204R0: libpda und pda sind zwei Projekte, aber ein + * Namespace, damit die Anwendung die Bibliothekstypen ohne Praefix benutzt. + * Auseinandergehalten werden sie ueber den Include-Pfad: + * + * #include Bibliothek + * #include Anwendung + * + * Die Shell kennt KEINE Ein-/Ausgabe. execute() bekommt eine Zeile und gibt + * Text zurueck; wer den Text anzeigt, ist ihre Sache nicht. Genau deshalb + * laesst sie sich ohne Terminal testen -- und genau deshalb koennte man + * spaeter eine GUI davorsetzen, ohne eine Zeile hier zu aendern. + */ +#ifndef PDA_SHELL_HPP +#define PDA_SHELL_HPP + +#include +#include +#include +#include + +#include +#include +#include + +namespace pda +{ + +struct CommandResult +{ + std::string output; + bool failed{false}; /* steuert den Exit-Code im Stapelbetrieb */ + bool quit{false}; +}; + +/* Zerlegt eine Eingabezeile in Argumente. Anfuehrungszeichen fassen zusammen, + * damit ein Kontaktname mit Leerzeichen ein Argument bleibt: + * + * contact add "Anna Schmidt" 0151 anna@example.org "meine Schwester" + * + * Frei stehend und oeffentlich, weil sie fuer sich testbar ist -- das ist die + * Stelle, an der Kommandozeilen-Parser ueblicherweise falsch liegen. */ +[[nodiscard]] std::vector +tokenize(std::string_view line); + +class Shell +{ +public: + explicit Shell(std::filesystem::path start_directory); + + /* Fuehrt genau eine Eingabezeile aus. Leere Zeilen und solche, die mit + * '#' beginnen, sind erlaubt und tun nichts -- damit lassen sich + * Skriptdateien kommentieren. */ + [[nodiscard]] CommandResult + execute(std::string_view line); + + [[nodiscard]] std::string + prompt() const; + + /* Zugriff fuer Tests und fuer eine spaetere andere Oberflaeche. */ + [[nodiscard]] const ContactBook& + contacts() const noexcept; + + [[nodiscard]] const TextBuffer& + notes() const noexcept; + + [[nodiscard]] const Explorer& + files() const noexcept; + +private: + ContactBook book; + TextBuffer buffer; + Explorer explorer; + + /* Ein Handler je Kommandogruppe. args[0] ist immer das Kommando selbst -- + * so wie argv in main(). + * + * "calc" fehlt hier absichtlich: es liest keinen Shell-Zustand und ist + * deshalb eine freie Funktion in shell.cpp. Was kein Member sein muss, + * soll auch keiner sein -- es schrumpft den Header und macht sichtbar, + * dass Rechnen nichts veraendert. */ + [[nodiscard]] CommandResult + do_contact(const std::vector& args); + + [[nodiscard]] CommandResult + do_note(const std::vector& args); + + [[nodiscard]] CommandResult + do_files(const std::vector& args); +}; + +/* Der Hilfetext. Frei stehend, damit main() ihn auch ohne Shell-Instanz + * ausgeben kann (etwa bei --help). */ +[[nodiscard]] std::string_view +help_text() noexcept; + +} /* namespace pda */ + +#endif /* PDA_SHELL_HPP */ diff --git a/pda/pda/shell.test.cpp b/pda/pda/shell.test.cpp new file mode 100644 index 0000000..5ba5215 --- /dev/null +++ b/pda/pda/shell.test.cpp @@ -0,0 +1,265 @@ +/* pda/shell.test.cpp + * Unit-Tests der Kommandoverarbeitung (doctest). + * + * Weil Shell::execute() Text zurueckgibt statt ihn zu drucken, braucht kein + * einziger dieser Tests ein Terminal, eine Pipe oder eine Umleitung. + */ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include + +#include + +namespace +{ + +pda::Shell +make_shell() +{ + return pda::Shell{std::filesystem::current_path()}; +} + +/* Fuer Vorbereitungszeilen, deren Ergebnis den Test nicht interessiert. + * execute() ist [[nodiscard]] -- das ist Absicht und soll auch im Test + * sichtbar bleiben, statt das Attribut aufzuweichen. Wer setup() liest, + * sieht: hier wird bewusst nicht geprueft. */ +void +setup(pda::Shell& shell, std::string_view line) +{ + (void) shell.execute(line); +} + +} /* namespace */ + +/* ---- Tokenizer ---- */ + +TEST_CASE("tokenize splits on whitespace") +{ + const auto tokens = pda::tokenize("contact list"); + REQUIRE(tokens.size() == 2U); + CHECK(tokens[0] == "contact"); + CHECK(tokens[1] == "list"); +} + +TEST_CASE("tokenize collapses repeated whitespace") +{ + const auto tokens = pda::tokenize(" calc \t 1 + 2 "); + REQUIRE(tokens.size() == 4U); + CHECK(tokens[0] == "calc"); + CHECK(tokens[3] == "2"); +} + +TEST_CASE("tokenize keeps quoted arguments together") +{ + const auto tokens = pda::tokenize(R"(contact add "Anna Schmidt" 0151)"); + REQUIRE(tokens.size() == 4U); + CHECK(tokens[2] == "Anna Schmidt"); +} + +TEST_CASE("tokenize preserves an explicitly empty argument") +{ + /* Ohne diesen Fall koennte man kein leeres Feld angeben -- ein Kontakt + * ohne E-Mail waere nicht eingebbar. */ + const auto tokens = pda::tokenize(R"(contact add Anna 0151 "" notiz)"); + REQUIRE(tokens.size() == 6U); + CHECK(tokens[4].empty()); + CHECK(tokens[5] == "notiz"); +} + +TEST_CASE("tokenize returns nothing for an empty line") +{ + CHECK(pda::tokenize("").empty()); + CHECK(pda::tokenize(" \t ").empty()); +} + +/* ---- Rechner ---- */ + +TEST_CASE("calc evaluates and formats without trailing zeros") +{ + pda::Shell shell = make_shell(); + + CHECK(shell.execute("calc 2 + 3 * 4").output == "14"); + CHECK(shell.execute("calc (2+3)*4").output == "20"); + CHECK(shell.execute("calc 2^10").output == "1024"); + CHECK(shell.execute("calc 3 / 2").output == "1.5"); +} + +TEST_CASE("calc reports an error and marks the result as failed") +{ + pda::Shell shell = make_shell(); + const pda::CommandResult result = shell.execute("calc 1 / 0"); + + CHECK(result.failed); + CHECK(result.output.find("Division durch null") != std::string::npos); +} + +TEST_CASE("calc without an expression explains itself") +{ + pda::Shell shell = make_shell(); + const pda::CommandResult result = shell.execute("calc"); + + CHECK(result.failed); + CHECK(result.output.starts_with("Aufruf:")); +} + +/* ---- Kontakte ---- */ + +TEST_CASE("contact add stores a contact reachable through the book") +{ + pda::Shell shell = make_shell(); + const pda::CommandResult result = + shell.execute(R"(contact add "Anna Schmidt" 0151 anna@example.org meine Schwester)"); + + CHECK_FALSE(result.failed); + REQUIRE(shell.contacts().size() == 1U); + + const pda::Contact* contact = shell.contacts().find("Anna Schmidt"); + REQUIRE(contact != nullptr); + CHECK(contact->phone == "0151"); + + /* Der Rest der Zeile wird zur Notiz zusammengesetzt. */ + CHECK(contact->note == "meine Schwester"); +} + +TEST_CASE("contact add rejects a duplicate") +{ + pda::Shell shell = make_shell(); + setup(shell, "contact add Anna 0151 a@b.c"); + + const pda::CommandResult result = shell.execute("contact add Anna 0170 x@y.z"); + CHECK(result.failed); + CHECK(shell.contacts().size() == 1U); +} + +TEST_CASE("contact add needs at least name, phone and mail") +{ + pda::Shell shell = make_shell(); + CHECK(shell.execute("contact add Anna 0151").failed); + CHECK(shell.contacts().empty()); +} + +TEST_CASE("contact list and find produce output") +{ + pda::Shell shell = make_shell(); + CHECK(shell.execute("contact list").output == "keine Kontakte"); + + setup(shell, "contact add Anna 0151 anna@example.org"); + setup(shell, "contact add Bert 0170 bert@firma.de"); + + CHECK(shell.execute("contact list").output.find("Anna") != std::string::npos); + CHECK(shell.execute("contact find firma").output.find("Bert") != std::string::npos); + CHECK(shell.execute("contact find zzz").output == "nichts gefunden"); +} + +TEST_CASE("contact del removes an existing contact only") +{ + pda::Shell shell = make_shell(); + setup(shell, "contact add Anna 0151 a@b.c"); + + CHECK_FALSE(shell.execute("contact del Anna").failed); + CHECK(shell.contacts().empty()); + CHECK(shell.execute("contact del Anna").failed); +} + +/* ---- Notizen ---- */ + +TEST_CASE("note add appends lines and list numbers them from one") +{ + pda::Shell shell = make_shell(); + setup(shell, "note add erste Zeile"); + setup(shell, "note add zweite Zeile"); + + REQUIRE(shell.notes().line_count() == 2U); + + const std::string listing = shell.execute("note list").output; + CHECK(listing.find(" 1 erste Zeile") != std::string::npos); + CHECK(listing.find(" 2 zweite Zeile") != std::string::npos); + CHECK(listing.find("ungespeicherte Aenderungen") != std::string::npos); +} + +TEST_CASE("note del rejects a non-numeric argument instead of throwing") +{ + pda::Shell shell = make_shell(); + setup(shell, "note add etwas"); + + const pda::CommandResult result = shell.execute("note del abc"); + CHECK(result.failed); + CHECK(result.output.find("keine Zahl") != std::string::npos); + + /* Der Puffer muss unveraendert sein. */ + CHECK(shell.notes().line_count() == 1U); +} + +TEST_CASE("note del reports an out-of-range line") +{ + pda::Shell shell = make_shell(); + CHECK(shell.execute("note del 5").failed); +} + +/* ---- Dateien ---- */ + +TEST_CASE("pwd reports the shell's own directory") +{ + pda::Shell shell = make_shell(); + CHECK(shell.execute("pwd").output == std::filesystem::current_path().string()); +} + +TEST_CASE("cd into a missing directory fails and changes nothing") +{ + pda::Shell shell = make_shell(); + const std::filesystem::path before = shell.files().current(); + + CHECK(shell.execute("cd gibtesnicht").failed); + CHECK(shell.files().current() == before); +} + +TEST_CASE("ls lists the current directory") +{ + pda::Shell shell = make_shell(); + const pda::CommandResult result = shell.execute("ls"); + + CHECK_FALSE(result.failed); + CHECK_FALSE(result.output.empty()); +} + +/* ---- Rahmen ---- */ + +TEST_CASE("quit sets the quit flag without output") +{ + pda::Shell shell = make_shell(); + + CHECK(shell.execute("quit").quit); + CHECK(shell.execute("exit").quit); +} + +TEST_CASE("blank lines and comments do nothing") +{ + pda::Shell shell = make_shell(); + + for (const char* line : {"", " ", "# ein Kommentar"}) + { + const pda::CommandResult result = shell.execute(line); + CHECK_FALSE(result.failed); + CHECK_FALSE(result.quit); + CHECK(result.output.empty()); + } +} + +TEST_CASE("an unknown command points at help") +{ + pda::Shell shell = make_shell(); + const pda::CommandResult result = shell.execute("fliegen"); + + CHECK(result.failed); + CHECK(result.output.find("help") != std::string::npos); +} + +TEST_CASE("help and version answer") +{ + pda::Shell shell = make_shell(); + + CHECK(shell.execute("help").output.find("calc") != std::string::npos); + CHECK(shell.execute("version").output == "pda 0.1.0"); +} diff --git a/pda/tests/CMakeLists.txt b/pda/tests/CMakeLists.txt new file mode 100644 index 0000000..38dc393 --- /dev/null +++ b/pda/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +# --------------------------------------------------------------------------- +# Integrationstests der Anwendung. +# +# Diese testen die Shell als Ganzes: eine Folge von Kommandos, wie ein +# Benutzer sie eintippen wuerde, und das Ergebnis am Ende. Kein Framework, +# Auskunft ueber den Exit-Code. +# --------------------------------------------------------------------------- + +function(pda_add_integration_test name) + set(target "integration_${name}") + + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${name}/driver.cpp") + message(WARNING "tests/${name}: keine driver.cpp gefunden") + return() + endif() + + add_executable(${target} "${name}/driver.cpp") + target_link_libraries(${target} PRIVATE pda_shell pda_warnings) + add_test(NAME "integration.${name}" COMMAND ${target}) +endfunction() + +pda_add_integration_test(session) diff --git a/pda/tests/session/driver.cpp b/pda/tests/session/driver.cpp new file mode 100644 index 0000000..c93882b --- /dev/null +++ b/pda/tests/session/driver.cpp @@ -0,0 +1,171 @@ +/* tests/session/driver.cpp + * Integrationstest der Anwendung. + * + * Spielt eine vollstaendige Sitzung durch -- dieselbe Folge von Kommandos, + * die ein Benutzer eintippen wuerde. Was hier gruen ist, funktioniert im + * Programm. + */ +#include +#include +#include +#include +#include + +#include + +#define CHECK(cond) \ + do \ + { \ + if (!(cond)) \ + { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + return 1; \ + } \ + } while (0) + +namespace fs = std::filesystem; + +namespace +{ + +/* Fuehrt eine Zeile aus und verlangt, dass sie GELINGT. Gibt die Meldung aus, + * wenn nicht -- sonst sucht man den Fehler in der falschen Zeile. */ +bool +run_ok(pda::Shell& shell, const std::string& line) +{ + const pda::CommandResult result = shell.execute(line); + if (result.failed) + { + std::fprintf(stderr, " Kommando '%s' scheiterte: %s\n", line.c_str(), + result.output.c_str()); + return false; + } + + return true; +} + +int +a_full_session_from_empty_to_saved(const fs::path& dir) +{ + pda::Shell shell{dir}; + + /* Leerer Anfang. */ + CHECK(shell.execute("contact list").output == "keine Kontakte"); + CHECK(shell.execute("note list").output == "Notizpuffer leer"); + + /* Kontakte anlegen -- mit Leerzeichen im Namen und in der Notiz. */ + CHECK(run_ok(shell, R"(contact add "Anna Schmidt" 0151 anna@example.org meine Schwester)")); + CHECK(run_ok(shell, R"(contact add "Bert Meier" 0170 bert@firma.de Kollege)")); + CHECK(shell.contacts().size() == 2U); + + /* Suchen. */ + CHECK(shell.execute("contact find schmidt").output.contains("Anna")); + CHECK(shell.execute("contact find zzz").output == "nichts gefunden"); + + /* Rechnen zwischendurch. */ + CHECK(shell.execute("calc 12 * 12").output == "144"); + + /* Notizen. */ + CHECK(run_ok(shell, "note add Anna anrufen")); + CHECK(run_ok(shell, "note add Rechnung bezahlen")); + CHECK(shell.notes().line_count() == 2U); + + CHECK(run_ok(shell, "note del 1")); + CHECK(shell.notes().line_count() == 1U); + CHECK(*shell.notes().line(1U) == "Rechnung bezahlen"); + + /* Sichern. */ + const std::string contacts_file = (dir / "kontakte.tsv").string(); + const std::string notes_file = (dir / "notizen.txt").string(); + + CHECK(run_ok(shell, "contact save " + contacts_file)); + CHECK(run_ok(shell, "note save " + notes_file)); + + /* Eine zweite Sitzung muss dasselbe wiederfinden. */ + pda::Shell restored{dir}; + CHECK(run_ok(restored, "contact load " + contacts_file)); + CHECK(run_ok(restored, "note load " + notes_file)); + + CHECK(restored.contacts().size() == 2U); + CHECK(restored.contacts().find("Anna Schmidt")->note == "meine Schwester"); + CHECK(restored.notes().line_count() == 1U); + + /* Der Explorer sieht beide Dateien. */ + const pda::CommandResult listing = restored.execute("ls"); + CHECK(!listing.failed); + CHECK(listing.output.contains("kontakte.tsv")); + CHECK(listing.output.contains("notizen.txt")); + + return 0; +} + +int +errors_do_not_corrupt_the_session(const fs::path& dir) +{ + pda::Shell shell{dir}; + CHECK(run_ok(shell, "contact add Anna 0151 a@b.c")); + + /* Eine Reihe von Fehlern -- danach muss der Zustand unveraendert sein. */ + const std::vector broken{"fliegen", + "calc 1 / 0", + "calc )(", + "contact add Anna", + "note del abc", + "cd gibtesnicht", + "contact load /nix/da.tsv"}; + + for (const std::string& line : broken) + { + const pda::CommandResult result = shell.execute(line); + if (!result.failed) + { + std::fprintf(stderr, " '%s' haette scheitern muessen\n", line.c_str()); + return 1; + } + } + + CHECK(shell.contacts().size() == 1U); + CHECK(shell.notes().empty()); + CHECK(shell.files().current() == dir); + + return 0; +} + +} /* namespace */ + +int +main() +{ + /* main darf nichts nach aussen werfen: eine entwichene Exception waere + * ein std::terminate, und CTest zeigte nur "Subprocess aborted" ohne + * jeden Hinweis, welcher Fall gescheitert ist. */ + try + { + const fs::path dir = fs::current_path() / "integration.session.tmp"; + + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir, ec); + if (ec) + { + std::fprintf(stderr, "FAIL: Arbeitsverzeichnis nicht anzulegen\n"); + return 1; + } + + int failures = 0; + failures += a_full_session_from_empty_to_saved(dir); + failures += errors_do_not_corrupt_the_session(dir); + + fs::remove_all(dir, ec); + + if (failures != 0) return 1; + + std::puts("integration/session: ok"); + return 0; + } + catch (const std::exception& error) + { + std::fprintf(stderr, "FAIL: unerwartete Exception: %s\n", error.what()); + return 1; + } +} diff --git a/playground/counter.c b/playground/counter.c deleted file mode 100644 index b48fd9d..0000000 --- a/playground/counter.c +++ /dev/null @@ -1,56 +0,0 @@ -/* playground/counter.c - * Implementierung des saettigenden Zaehlers. - */ -#include - -#include - -struct Counter -{ - uint32_t value; - uint32_t limit; -}; - -Counter* -counter_create(uint32_t limit) -{ - Counter* counter = malloc(sizeof(*counter)); - if (!counter) return NULL; - - counter->value = 0U; - counter->limit = limit; - return counter; -} - -void -counter_free(Counter* counter) -{ - /* free(NULL) ist definiert -- der Aufrufer braucht keine eigene Pruefung. */ - free(counter); -} - -uint32_t -counter_tick(Counter* counter) -{ - if (counter->value < counter->limit) counter->value++; - - return counter->value; -} - -uint32_t -counter_value(const Counter* counter) -{ - return counter->value; -} - -int -counter_saturated(const Counter* counter) -{ - return counter->value >= counter->limit; -} - -void -counter_reset(Counter* counter) -{ - counter->value = 0U; -} diff --git a/playground/counter.h b/playground/counter.h deleted file mode 100644 index f6fa52b..0000000 --- a/playground/counter.h +++ /dev/null @@ -1,48 +0,0 @@ -/* playground/counter.h - * Ein Zaehler mit saettigender Obergrenze (C). - * - * Geruest-Modul: es zeigt die C-Seite der Projektstruktur und ist dazu da, - * ersetzt zu werden. Was daran bleiben soll, ist die Form -- opakes Struct, - * Modulpraefix an jeder Funktion, extern "C" fuer die C++-Seite. - */ -#ifndef PLAYGROUND_COUNTER_H -#define PLAYGROUND_COUNTER_H - -#include -#include - -#ifdef __cplusplus -extern "C" -{ -#endif - -/* Opak: das Layout geht niemanden an, die Definition steht in counter.c. */ -typedef struct Counter Counter; - -/* NULL, wenn kein Speicher da ist. Bei limit == 0 saettigt der Zaehler sofort. */ -Counter* -counter_create(uint32_t limit); - -void -counter_free(Counter* counter); - -/* Erhoeht um eins und gibt den neuen Wert zurueck. Bei erreichtem Limit - * bleibt der Wert stehen -- kein Ueberlauf, kein Fehler. */ -uint32_t -counter_tick(Counter* counter); - -uint32_t -counter_value(const Counter* counter); - -/* Nicht null, sobald das Limit erreicht ist. */ -int -counter_saturated(const Counter* counter); - -void -counter_reset(Counter* counter); - -#ifdef __cplusplus -} /* extern "C" */ -#endif - -#endif /* PLAYGROUND_COUNTER_H */ diff --git a/playground/counter.test.c b/playground/counter.test.c deleted file mode 100644 index 5b47184..0000000 --- a/playground/counter.test.c +++ /dev/null @@ -1,102 +0,0 @@ -/* playground/counter.test.c - * Unit-Tests fuer counter (Unity). - * - * Liegt nach P1204R0 Regel 7.1 direkt neben counter.c und darf dessen Interna - * kennen. CMake baut daraus die Executable "counter.test" und registriert sie - * als CTest "unity.counter" -- genau diesen Namen erwartet der neotest-Adapter. - */ -#include - -#include - -void -setUp(void) -{ -} - -void -tearDown(void) -{ -} - -static void -test_fresh_counter_starts_at_zero(void) -{ - Counter* counter = counter_create(3U); - TEST_ASSERT_NOT_NULL(counter); - - TEST_ASSERT_EQUAL_UINT32(0U, counter_value(counter)); - TEST_ASSERT_FALSE(counter_saturated(counter)); - - counter_free(counter); -} - -static void -test_tick_increments_and_returns_new_value(void) -{ - Counter* counter = counter_create(3U); - TEST_ASSERT_NOT_NULL(counter); - - TEST_ASSERT_EQUAL_UINT32(1U, counter_tick(counter)); - TEST_ASSERT_EQUAL_UINT32(2U, counter_tick(counter)); - TEST_ASSERT_EQUAL_UINT32(2U, counter_value(counter)); - - counter_free(counter); -} - -static void -test_counter_saturates_at_limit(void) -{ - Counter* counter = counter_create(2U); - TEST_ASSERT_NOT_NULL(counter); - - counter_tick(counter); - counter_tick(counter); - TEST_ASSERT_TRUE(counter_saturated(counter)); - - /* Weitere Ticks duerfen den Wert nicht mehr veraendern. */ - TEST_ASSERT_EQUAL_UINT32(2U, counter_tick(counter)); - TEST_ASSERT_EQUAL_UINT32(2U, counter_tick(counter)); - - counter_free(counter); -} - -static void -test_limit_zero_saturates_immediately(void) -{ - Counter* counter = counter_create(0U); - TEST_ASSERT_NOT_NULL(counter); - - TEST_ASSERT_TRUE(counter_saturated(counter)); - TEST_ASSERT_EQUAL_UINT32(0U, counter_tick(counter)); - - counter_free(counter); -} - -static void -test_reset_returns_to_zero(void) -{ - Counter* counter = counter_create(5U); - TEST_ASSERT_NOT_NULL(counter); - - counter_tick(counter); - counter_tick(counter); - counter_reset(counter); - - TEST_ASSERT_EQUAL_UINT32(0U, counter_value(counter)); - TEST_ASSERT_FALSE(counter_saturated(counter)); - - counter_free(counter); -} - -int -main(void) -{ - UNITY_BEGIN(); - RUN_TEST(test_fresh_counter_starts_at_zero); - RUN_TEST(test_tick_increments_and_returns_new_value); - RUN_TEST(test_counter_saturates_at_limit); - RUN_TEST(test_limit_zero_saturates_immediately); - RUN_TEST(test_reset_returns_to_zero); - return UNITY_END(); -} diff --git a/playground/details/bits.h b/playground/details/bits.h deleted file mode 100644 index 56c24de..0000000 --- a/playground/details/bits.h +++ /dev/null @@ -1,41 +0,0 @@ -/* playground/details/bits.h - * Implementation Detail -- kleine Groessenhelfer. - * - * details/ ist nach P1204R0 die mittlere Ebene: technisch mitinstalliert und - * von anderen Uebersetzungseinheiten des Projekts benutzbar, aber ausdruecklich - * NICHT Teil der oeffentlichen API. Wer inkludiert, - * weiss damit, dass er sich auf Internes stuetzt. - * - * Die Datei ist das Beispiel fuer genau diese Ebene und wird noch von niemandem - * benutzt -- der erste echte Helfer gehoert hierher, nicht neben die - * oeffentliche API. - */ -#ifndef PLAYGROUND_DETAILS_BITS_H -#define PLAYGROUND_DETAILS_BITS_H - -#include - -#ifdef __cplusplus -extern "C" -{ -#endif - -/* Rundet auf das naechste Vielfache von align auf. align muss eine Zweierpotenz - * sein -- das ist der Grund fuer die Maske statt einer Division. */ -static inline size_t -bits_align_up(size_t value, size_t align) -{ - return (value + (align - 1U)) & ~(align - 1U); -} - -static inline int -bits_is_power_of_two(size_t value) -{ - return value != 0U && (value & (value - 1U)) == 0U; -} - -#ifdef __cplusplus -} /* extern "C" */ -#endif - -#endif /* PLAYGROUND_DETAILS_BITS_H */ diff --git a/playground/main.cpp b/playground/main.cpp deleted file mode 100644 index 5792181..0000000 --- a/playground/main.cpp +++ /dev/null @@ -1,74 +0,0 @@ -/* playground/main.cpp - * Einstiegspunkt. - * - * Zeigt die Grenze zwischen beiden Sprachen: der Counter kommt aus C und wird - * ueber den extern "C"-Header benutzt, das Notebook ist C++. Beide werden mit - * Projektpraefix und spitzen Klammern inkludiert -- P1204R0s wichtigste - * Einzelregel. - */ -#include -#include -#include -#include - -#include -#include - -namespace -{ - -int -demo() -{ - Counter* counter = counter_create(3U); - if (!counter) - { - std::fputs("counter_create failed\n", stderr); - return 1; - } - - playground::Notebook notebook; - - /* Ticken, bis der Zaehler saettigt, und jeden Schritt protokollieren. */ - for (int step = 0; step < 5; ++step) - { - const std::uint32_t value = counter_tick(counter); - notebook.append("tick", std::to_string(value)); - } - - for (const auto& entry : notebook.entries()) - { - std::printf("%s: %s\n", entry.label.c_str(), entry.text.c_str()); - } - - const auto last = notebook.latest("tick"); - std::printf("saturated: %s, letzter Wert: %s\n", counter_saturated(counter) ? "ja" : "nein", - last ? std::string(*last).c_str() : "-"); - - counter_free(counter); - return 0; -} - -} /* namespace */ - -int -main() -{ - /* main darf nichts nach aussen werfen (bugprone-exception-escape): das - * Notebook allokiert, und eine entwichene Exception waere ein - * std::terminate ohne verwertbare Meldung. */ - try - { - return demo(); - } - catch (const std::exception& error) - { - std::fprintf(stderr, "playground: %s\n", error.what()); - return 1; - } - catch (...) - { - std::fputs("playground: unbekannter Fehler\n", stderr); - return 1; - } -} diff --git a/playground/notebook.cpp b/playground/notebook.cpp deleted file mode 100644 index e3d43f4..0000000 --- a/playground/notebook.cpp +++ /dev/null @@ -1,48 +0,0 @@ -/* playground/notebook.cpp - * Implementierung des Notizbuchs. - */ -#include - -#include - -namespace playground -{ - -void -Notebook::append(std::string_view label, std::string_view text) -{ - items.push_back(Entry{std::string(label), std::string(text)}); -} - -std::optional -Notebook::latest(std::string_view label) const -{ - /* Von hinten, damit der juengste Eintrag gewinnt -- ein Vorwaertslauf - * muesste bis zum Ende weitersuchen, um dasselbe zu liefern. */ - for (auto it = items.rbegin(); it != items.rend(); ++it) - { - if (it->label == label) return std::string_view{it->text}; - } - - return std::nullopt; -} - -const std::vector& -Notebook::entries() const noexcept -{ - return items; -} - -std::size_t -Notebook::size() const noexcept -{ - return items.size(); -} - -void -Notebook::clear() noexcept -{ - items.clear(); -} - -} /* namespace playground */ diff --git a/playground/notebook.hpp b/playground/notebook.hpp deleted file mode 100644 index d102e47..0000000 --- a/playground/notebook.hpp +++ /dev/null @@ -1,60 +0,0 @@ -/* playground/notebook.hpp - * Ein Notizbuch: beschriftete Textschnipsel in Einfuegereihenfolge (C++). - * - * Geruest-Modul fuer die C++-Seite. Gleicher Stil, gleiche Klammern, gleiche - * Kommentarform wie im C-Teil -- der Unterschied ist allein die Extension. - */ -#ifndef PLAYGROUND_NOTEBOOK_HPP -#define PLAYGROUND_NOTEBOOK_HPP - -#include -#include -#include -#include -#include - -namespace playground -{ - -/* Namespace heisst playground, das Verzeichnis heisst playground, Makros - * heissen PLAYGROUND_* -- so kollidiert nichts mit anderen Projekten. */ -class Notebook -{ -public: - struct Entry - { - std::string label; - std::string text; - }; - - /* Haengt hinten an. Anders als bei Registry in mydb sind doppelte Label - * erlaubt -- ein Notizbuch protokolliert, es indiziert nicht. */ - void - append(std::string_view label, std::string_view text); - - /* nullopt, wenn kein Eintrag dieses Label traegt. Bei Mehrfachvergabe - * gewinnt der ZULETZT angehaengte. - * - * Die zurueckgegebene View zeigt in das Notizbuch: sie gilt nur so lange, - * wie das Notizbuch lebt und nicht veraendert wird. */ - std::optional - latest(std::string_view label) const; - - const std::vector& - entries() const noexcept; - - std::size_t - size() const noexcept; - - void - clear() noexcept; - -private: - /* Kein m_-Praefix und kein abschliessender Unterstrich -- so wie im - * restlichen Projekt. */ - std::vector items; -}; - -} /* namespace playground */ - -#endif /* PLAYGROUND_NOTEBOOK_HPP */ diff --git a/playground/notebook.test.cpp b/playground/notebook.test.cpp deleted file mode 100644 index fd5e0a4..0000000 --- a/playground/notebook.test.cpp +++ /dev/null @@ -1,60 +0,0 @@ -/* playground/notebook.test.cpp - * Unit-Tests fuer Notebook (doctest). - * - * Liegt nach P1204R0 Regel 7.1 neben notebook.cpp. CMake baut daraus die - * Executable "notebook.test" und laesst doctest_discover_tests() darauf los -- - * das legt pro TEST_CASE einen eigenen CTest-Eintrag an, was neotest braucht, - * um einzelne Faelle rot bzw. gruen zu faerben. - */ -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include - -#include - -TEST_CASE("a fresh notebook is empty") -{ - const playground::Notebook notebook; - - CHECK(notebook.size() == 0U); - CHECK(notebook.entries().empty()); - CHECK_FALSE(notebook.latest("nothing").has_value()); -} - -TEST_CASE("append stores label and text in order") -{ - playground::Notebook notebook; - notebook.append("first", "eins"); - notebook.append("second", "zwei"); - - REQUIRE(notebook.size() == 2U); - CHECK(notebook.entries()[0].label == "first"); - CHECK(notebook.entries()[1].text == "zwei"); -} - -TEST_CASE("latest returns the most recent entry for a repeated label") -{ - playground::Notebook notebook; - notebook.append("note", "alt"); - notebook.append("other", "dazwischen"); - notebook.append("note", "neu"); - - const auto found = notebook.latest("note"); - REQUIRE(found.has_value()); - CHECK(*found == "neu"); - - /* Der aeltere Eintrag bleibt erhalten, er wird nur nicht gefunden. */ - CHECK(notebook.size() == 3U); -} - -TEST_CASE("clear empties the notebook") -{ - playground::Notebook notebook; - notebook.append("a", "1"); - notebook.append("b", "2"); - REQUIRE(notebook.size() == 2U); - - notebook.clear(); - - CHECK(notebook.size() == 0U); - CHECK_FALSE(notebook.latest("a").has_value()); -} diff --git a/tests/basics/driver.cpp b/tests/basics/driver.cpp deleted file mode 100644 index 58d4819..0000000 --- a/tests/basics/driver.cpp +++ /dev/null @@ -1,89 +0,0 @@ -/* tests/basics/driver.cpp - * Integrationstest (P1204R0 Regel 7.2). - * - * Unterschied zu den .test-Dateien neben den Modulen: dieser Test kennt NUR - * die oeffentliche API. Kein unity.h, kein doctest.h, kein Zugriff auf - * details/, keine Kenntnis vom Layout. Er wuerde genauso gegen eine - * installierte Bibliothek laufen -- und testet damit das, was ein Benutzer - * tatsaechlich bekommt. - * - * Deshalb auch kein Test-Framework: ein Integrationstest soll ein - * eigenstaendiges Programm sein, das ohne Argumente laeuft und ueber seinen - * Exit-Code Auskunft gibt. - */ -#include -#include - -#include -#include - -#define CHECK(cond) \ - do \ - { \ - if (!(cond)) \ - { \ - std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ - return 1; \ - } \ - } while (0) - -namespace -{ - -/* Beide Module zusammen: der C-Zaehler treibt, das C++-Notizbuch protokolliert. - * Genau diese Kombination ist das, was main.cpp einem Benutzer vormacht. */ -int -counter_drives_notebook_until_saturation() -{ - Counter* counter = counter_create(3U); - CHECK(counter != nullptr); - - playground::Notebook notebook; - - for (int step = 0; step < 5; ++step) - { - notebook.append("tick", std::to_string(counter_tick(counter))); - } - - /* Fuenf Ticks, aber Limit 3: der Zaehler steht, das Protokoll nicht. */ - CHECK(notebook.size() == 5U); - CHECK(counter_value(counter) == 3U); - CHECK(counter_saturated(counter)); - - const auto last = notebook.latest("tick"); - CHECK(last.has_value()); - CHECK(*last == "3"); - - counter_free(counter); - return 0; -} - -int -reset_makes_the_counter_usable_again() -{ - Counter* counter = counter_create(2U); - CHECK(counter != nullptr); - - counter_tick(counter); - counter_tick(counter); - CHECK(counter_saturated(counter)); - - counter_reset(counter); - CHECK(!counter_saturated(counter)); - CHECK(counter_tick(counter) == 1U); - - counter_free(counter); - return 0; -} - -} /* namespace */ - -int -main() -{ - if (counter_drives_notebook_until_saturation() != 0) return 1; - if (reset_makes_the_counter_usable_again() != 0) return 1; - - std::puts("integration/basics: ok"); - return 0; -} diff --git a/tools/check-install.sh b/tools/check-install.sh new file mode 100755 index 0000000..85ba628 --- /dev/null +++ b/tools/check-install.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# --------------------------------------------------------------------------- +# Prueft den Export-Mechanismus der Bibliothek Ende-zu-Ende: +# +# 1. libpda allein konfigurieren und bauen +# 2. in ein temporaeres Praefix installieren +# 3. examples/consumer/ DAGEGEN konfigurieren, bauen und laufen lassen +# +# Schritt 3 ist der eigentliche Test. Er benutzt find_package(pda) und weiss +# nichts vom Quellbaum -- genau wie ein fremdes Projekt. Wenn hier etwas +# bricht, ist install(EXPORT) oder pdaConfig.cmake.in falsch, und zwar auf +# eine Art, die im normalen Build NICHT auffaellt. +# +# Der uebliche Fehler, den genau dieses Skript faengt: ein +# target_include_directories ohne BUILD_INTERFACE/INSTALL_INTERFACE. Im +# Quellbaum laeuft alles, beim Benutzer zeigt der Pfad ins Leere. +# --------------------------------------------------------------------------- +set -eu + +root=$(cd "$(dirname "$0")/.." && pwd) +work=${TMPDIR:-/tmp}/pda-install-check.$$ +prefix="$work/prefix" + +cleanup() { rm -rf "$work"; } +trap cleanup EXIT + +echo "==> 1/3 libpda allein bauen" +cmake -S "$root/libpda" -B "$work/lib" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DPDA_BUILD_TESTS=OFF \ + -DCMAKE_INSTALL_PREFIX="$prefix" >/dev/null +cmake --build "$work/lib" >/dev/null + +echo "==> 2/3 installieren nach $prefix" +cmake --install "$work/lib" >/dev/null +echo " installiert:" +find "$prefix" -type f | sed "s|$prefix| \$prefix|" | sort + +echo "==> 3/3 examples/consumer gegen die INSTALLIERTE Bibliothek bauen" +cmake -S "$root/examples/consumer" -B "$work/consumer" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$prefix" >/dev/null +cmake --build "$work/consumer" >/dev/null + +echo " Ausgabe des fremden Programms:" +"$work/consumer/consumer" | sed 's/^/ /' + +echo "==> OK: find_package(pda) funktioniert."