feat: PDA als libpda + pda nach P1204R0

Ersetzt das Geruest (counter/notebook) durch ein echtes Beispiel und teilt
es in zwei eigenstaendige Projekte, wie P1204R0 es fuer Bibliothek plus
Programm verlangt.

libpda: textfile (C, Datei-I/O und Feld-Escaping), calculator
(Recursive-Descent-Parser mit std::expected), contact, editor, explorer.
Exportiert pda::pda ueber install(EXPORT) und ist per find_package(pda)
benutzbar.

pda: Shell ohne Ein-/Ausgabe (execute() gibt Text zurueck, deshalb ohne
Terminal testbar), REPL und Stapelbetrieb in main.cpp.

examples/consumer und tools/check-install.sh pruefen die Export-Kette
Ende-zu-Ende: installieren, dann ein fremdes Projekt dagegen bauen.

docs/CMAKE.md erklaert das Target-Modell, PUBLIC/PRIVATE/INTERFACE,
Generator-Ausdruecke, install/export und Symbolsichtbarkeit an diesem
Projekt.

71 Tests gruen unter Homebrew-clang 22, Apple clang und GCC 16, statisch
und shared, mit und ohne ASan/UBSan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 16:39:35 +02:00
parent da43859b97
commit db41258d16
50 changed files with 5116 additions and 827 deletions
+18 -1
View File
@@ -6,6 +6,21 @@
# doctest.h. Running live through clangd, that floods the editor with # doctest.h. Running live through clangd, that floods the editor with
# diagnostics you cannot act on. Scoped to this project's own source tree # 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. # 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: > Checks: >
bugprone-*, bugprone-*,
cert-*, cert-*,
@@ -20,7 +35,9 @@ Checks: >
-readability-braces-around-statements, -readability-braces-around-statements,
-cert-err33-c, -cert-err33-c,
-misc-include-cleaner, -misc-include-cleaner,
-readability-implicit-bool-conversion -readability-implicit-bool-conversion,
-misc-no-recursion,
-performance-enum-size
CheckOptions: CheckOptions:
- key: bugprone-easily-swappable-parameters.MinimumLength - key: bugprone-easily-swappable-parameters.MinimumLength
+40 -153
View File
@@ -1,180 +1,67 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# playground # playground - Superprojekt
# #
# Struktur nach P1204R0 "Canonical Project Structure": # Dieses Verzeichnis ist KEIN Projekt mit eigenem Quellcode. Es haelt nur zwei
# playground/playground/ Quellcode UND Header nebeneinander (kein include/ + src/) # eigenstaendige Projekte zusammen:
# playground/tests/ nur Integrationstests gegen die oeffentliche API
# *.test.c/.cpp Unit-Tests direkt neben dem getesteten Modul
# #
# Include-Konvention: eigene Header immer <playground/foo.h>, nie "foo.h". # libpda/ die Bibliothek -> libpda.a bzw. libpda.dylib
# Spitze Klammern durchsuchen nur die -I-Pfade, niemals das Verzeichnis der # pda/ die Anwendung -> bin/pda
# inkludierenden Datei -- ein fehlender Header fliegt damit sofort auf, statt
# dass zufaellig ein gleichnamiger lokaler Header gefunden wird.
# #
# Extensions: .h/.c fuer C, .hpp/.cpp fuer C++. Die Extension sagt also die # P1204R0 verlangt genau diese Trennung ("If a project consists of a library
# Sprache -- in einem gemischten Projekt ist das der Punkt. # 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 # 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 # hochziehen: das laesst sich auf keinem LTS-Server mehr konfigurieren.
# LTS-Server mehr konfigurieren.
cmake_minimum_required(VERSION 3.28) cmake_minimum_required(VERSION 3.28)
project(playground project(playground
VERSION 0.1.0 VERSION 0.1.0
DESCRIPTION "Experimentierprojekt nach P1204R0" DESCRIPTION "PDA: Bibliothek und Anwendung"
LANGUAGES C CXX) LANGUAGES C CXX)
# CMAKE_MODULE_PATH ist die Suchliste fuer include(<name>) und
# find_package(<name> MODULE). Ohne diese Zeile findet include(Warnings)
# nichts.
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# --------------------------------------------------------------------------- include(ProjectDefaults)
# 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/<target>.dir/<datei>.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(Warnings) include(Warnings)
include(Sanitizers) 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 # cmake --preset debug -DPDA_BUILD_TESTS=OFF
# Build bleibt gruen und die neue Datei ist einfach nicht dabei.
# #
# *.test.c und *.test.cpp gehoeren NICHT hierher (P1204R0 Regel 7.1) -- die # BUILD_SHARED_LIBS ist eine CMake-Konvention: add_library() ohne STATIC/SHARED
# werden unten zu eigenen Executables. # richtet sich danach. Deshalb hier NICHT selbst erfinden, sondern genau diesen
# Namen benutzen -- jedes Werkzeug da draussen kennt ihn.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set(PLAYGROUND_SOURCES option(BUILD_SHARED_LIBS "Bibliothek als Shared Library bauen" OFF)
playground/counter.c option(PDA_BUILD_TESTS "Unit- und Integrationstests bauen" ON)
playground/notebook.cpp
)
set(PLAYGROUND_HEADERS # enable_testing() gehoert ins OBERSTE CMakeLists.txt und nirgendwo sonst.
playground/counter.h # Nur hier legt es die CTestTestfile.cmake an, die ctest im Wurzel-Build-
playground/notebook.hpp # verzeichnis sucht.
playground/details/bits.h if(PDA_BUILD_TESTS)
) enable_testing()
add_library(playground_core STATIC ${PLAYGROUND_SOURCES} ${PLAYGROUND_HEADERS})
# Der Include-Root ist das PROJEKTWURZELVERZEICHNIS, nicht playground/playground.
# Nur so loest <playground/counter.h> 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)
endif() endif()
if(PLAYGROUND_HAVE_UNITY OR PLAYGROUND_HAVE_DOCTEST) add_subdirectory(third_party)
add_subdirectory(third_party) add_subdirectory(libpda)
else() add_subdirectory(pda)
message(WARNING "third_party/ ist leer -- keine Unit-Test-Targets.")
endif()
# Unit-Test in C: <modul>.test.c -> Executable <modul>.test -> CTest
# "unity.<modul>". 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++: <modul>.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)
message(STATUS "playground ${PROJECT_VERSION} | C${CMAKE_C_STANDARD} C++${CMAKE_CXX_STANDARD} | " 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}")
+1 -1
View File
@@ -34,7 +34,7 @@
"displayName": "Debug + AddressSanitizer + UBSan", "displayName": "Debug + AddressSanitizer + UBSan",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug", "CMAKE_BUILD_TYPE": "Debug",
"PLAYGROUND_SANITIZE": "address,undefined" "PDA_SANITIZE": "address,undefined"
} }
}, },
+92 -32
View File
@@ -1,32 +1,37 @@
# playground # playground
Experimentierprojekt. Struktur nach Ein kleiner PDA — Kontakte, Taschenrechner, Notizen, Dateiexplorer — als
[P1204R0 "Canonical Project Structure"](https://wg21.link/p1204r0), Konventionen Übungsprojekt für die Bau- und Projektstruktur, die dahintersteht.
und Build-Setup identisch zu `mydb` und `cxx_scaffold_cli`.
Der Punkt dieses Repos ist die Form, nicht der Inhalt: `counter` (C) und Aufgeteilt nach [P1204R0 „Canonical Project
`notebook` (C++) sind Gerüst-Module, die jede Regel einmal an lauffähigem Code Structure"](https://wg21.link/p1204r0) in **zwei eigenständige Projekte**:
zeigen. Zum Experimentieren werden sie ersetzt.
## Layout
``` ```
playground/ playground/ Superprojekt, kein eigener Quellcode
├── playground/ Quellcode UND Header nebeneinander ├── libpda/ die Bibliothek -> libpda.a
│ ├── counter.h/.c C-Modul, opakes Struct, extern "C" │ ├── libpda/
│ ├── counter.test.c Unit-Test daneben (Unity) │ │ ├── textfile.h/.c C: Datei-I/O und Feld-Escaping
│ ├── notebook.hpp/.cpp C++-Modul, namespace playground │ │ ├── calculator.hpp/.cpp Ausdrucksparser (Recursive Descent)
│ ├── notebook.test.cpp Unit-Test daneben (doctest) │ │ ├── contact.hpp/.cpp Kontaktverwaltung mit Persistenz
│ ├── main.cpp │ │ ├── editor.hpp/.cpp zeilenorientierter Textpuffer
│ └── details/ Implementation Details, nicht public API │ │ ├── explorer.hpp/.cpp Verzeichnisnavigation
├── tests/basics/ Integrationstest, nur öffentliche API │ │ ├── *.test.c/.cpp Unit-Tests, direkt neben dem Modul
├── third_party/ vendortes Unity + doctest, nie editieren │ │ └── details/ Implementation Details
├── cmake/ Warnings.cmake, Sanitizers.cmake │ └── tests/basics/ Integrationstest, nur öffentliche API
└── docs/ CONVENTIONS.md, ADRs ├── 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 `<playground/counter.h>`, nie **Warum getrennte Projekte:** P1204R0 verlangt es, und der praktische Grund
`"counter.h"` — das ist P1204R0s wichtigste Einzelregel. ist, dass die Bibliothek ohne die Anwendung baubar, testbar und installierbar
sein muss — sonst ist sie keine Bibliothek, sondern ein Unterverzeichnis.
## Bauen ## Bauen
@@ -34,18 +39,73 @@ Kein `include/` + `src/`. Eigene Header immer `<playground/counter.h>`, nie
cmake --preset debug cmake --preset debug
cmake --build --preset debug cmake --build --preset debug
ctest --preset debug ctest --preset debug
./build/debug/bin/playground ./build/debug/bin/pda
``` ```
Presets: `debug`, `release`, `asan-ubsan`, `apple-clang`, `homebrew-gcc`, 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/<name>.hpp` + `.cpp` anlegen (bzw. `.h`/`.c` für C). Interaktiv:
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. $ pda
3. `playground/<name>.test.cpp` daneben, dann pda 0.1.0 -- 'help' zeigt die Kommandos, 'quit' beendet.
`playground_add_doctest_test(<name>)` aufrufen (C: `.test.c` und playground> contact add "Anna Schmidt" 0151 anna@example.org meine Schwester
`playground_add_unity_test(<name>)`). '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 <libpda/calculator.hpp>
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.
+76
View File
@@ -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/<target>.dir/<datei>.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()
+10 -8
View File
@@ -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). # (das asan-ubsan-Preset setzt genau das).
# #
# Warum das gerade hier zaehlt: in einem Playground steht oft genau der Code, # Warum das gerade hier zaehlt: in einem Playground steht oft genau der Code,
@@ -8,17 +8,19 @@
# assert-Test sieht das nie, ASan sofort. # 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") "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")) 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() return()
endif() endif()
set(_flags set(_flags
-fsanitize=${PLAYGROUND_SANITIZE} -fsanitize=${PDA_SANITIZE}
# Ohne Frame-Pointer sind die ASan-Stacks unbrauchbar. # Ohne Frame-Pointer sind die ASan-Stacks unbrauchbar.
-fno-omit-frame-pointer -fno-omit-frame-pointer
@@ -27,7 +29,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "")
-fno-optimize-sibling-calls -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, # Entscheidend: ohne das MELDET UBSan den Fehler nur und laeuft weiter,
# der Test wird gruen und die Meldung verschwindet im Scrollback. # der Test wird gruen und die Meldung verschwindet im Scrollback.
# So schlaegt der Test tatsaechlich fehl. # So schlaegt der Test tatsaechlich fehl.
@@ -35,7 +37,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "")
endif() endif()
add_compile_options(${_flags}) 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() endif()
+12 -7
View File
@@ -10,9 +10,14 @@
# live in Neovim angezeigt, weil clangd sie ohnehin liefert. # 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 -Wall
-Wextra -Wextra
-Wpedantic -Wpedantic
@@ -44,7 +49,7 @@ set(PLAYGROUND_GCC_LIKE_WARNINGS
# rohen Puffern experimentiert, sichert die Ausrichtung im Code zu # rohen Puffern experimentiert, sichert die Ausrichtung im Code zu
# (alignas(...)), wo sie ueberpruefbar ist. # (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 -- # Ein () statt (void) in C ist eine Funktion mit unbekannten Parametern --
# in C23 zwar geheilt, aber die Warnung haelt aelteren Code ehrlich. # in C23 zwar geheilt, aber die Warnung haelt aelteren Code ehrlich.
-Wstrict-prototypes -Wstrict-prototypes
@@ -52,13 +57,13 @@ set(PLAYGROUND_C_ONLY_WARNINGS
-Wold-style-definition -Wold-style-definition
) )
set(PLAYGROUND_CXX_ONLY_WARNINGS set(PDA_CXX_ONLY_WARNINGS
-Wnon-virtual-dtor -Wnon-virtual-dtor
-Woverloaded-virtual -Woverloaded-virtual
-Wold-style-cast -Wold-style-cast
) )
target_compile_options(playground_warnings INTERFACE target_compile_options(pda_warnings INTERFACE
$<$<COMPILE_LANG_AND_ID:C,GNU,Clang,AppleClang>:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_C_ONLY_WARNINGS}> $<$<COMPILE_LANG_AND_ID:C,GNU,Clang,AppleClang>:${PDA_GCC_LIKE_WARNINGS};${PDA_C_ONLY_WARNINGS}>
$<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_CXX_ONLY_WARNINGS}> $<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:${PDA_GCC_LIKE_WARNINGS};${PDA_CXX_ONLY_WARNINGS}>
) )
+28
View File
@@ -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)
+507
View File
@@ -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) # <expected> 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 <libpda/contact.hpp>` 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:${CMAKE_CURRENT_SOURCE_DIR}>"
"$<INSTALL_INTERFACE:include>")
```
- **`BUILD_INTERFACE`** gilt, solange im Quellbaum gebaut wird. Include-Root
ist `<repo>/libpda`, damit `<libpda/contact.hpp>` auf
`<repo>/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 "$<BUILD_INTERFACE:pda_warnings>")
```
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 `$<LINK_ONLY:...>`
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.
```
`$<BUILD_INTERFACE:...>` 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 <libpda/pda_export.h>
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/<preset>` 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/<name>.hpp` + `.cpp` anlegen
2. Beide in `LIBPDA_SOURCES` / `LIBPDA_HEADERS` eintragen — **kein `file(GLOB)`**
3. Öffentliche Deklarationen mit `PDA_EXPORT` markieren
4. `libpda/libpda/<name>.test.cpp` daneben, `libpda_add_doctest_test(<name>)`
**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 "$<$<CONFIG:Debug>: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.
+59 -33
View File
@@ -8,39 +8,59 @@ ebenfalls nicht) — die folgenden Regeln gelten also per Konvention.
## Projektstruktur (P1204R0) ## Projektstruktur (P1204R0)
``` Zwei eigenständige Projekte unter einem Superprojekt:
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
```
`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, playground/ Superprojekt, kein eigener Quellcode
ist die Form, nicht der Inhalt. ├── 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. **Kein `include/` + `src/`.** Header und Implementierung liegen nebeneinander.
Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber zu
zu ziehen, und der Verzeichnisname `playground/` übernimmt die Rolle des Namespace ziehen, und der Verzeichnisname übernimmt die Rolle des Namespace im
im Dateisystem. 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 <libpda/contact.hpp> // Bibliothek
#include <pda/shell.hpp> // Anwendung
```
Das ist genau das Schema aus P1204R0 (`libhello` / `hello`): das `lib`-Präfix
steht im Projekt- und Verzeichnisnamen, nicht im Namespace.
## Includes ## Includes
**Eigene Header immer mit spitzen Klammern und Projektpräfix:** **Eigene Header immer mit spitzen Klammern und Projektpräfix:**
```c ```c
#include <playground/counter.h> /* richtig */ #include <libpda/textfile.h> /* richtig */
#include "counter.h" /* falsch */ #include "textfile.h" /* falsch */
``` ```
Das ist P1204R0s wichtigste Einzelregel. Spitze Klammern durchsuchen nur die Das ist P1204R0s wichtigste Einzelregel. Spitze Klammern durchsuchen nur die
@@ -68,13 +88,13 @@ ein `.hpp` nur aus C++.
| Was | Form | Beispiel | | Was | Form | Beispiel |
|---|---|---| |---|---|---|
| Typen | `PascalCase` | `Counter`, `Notebook` | | Typen | `PascalCase` | `ContactBook`, `TextBuffer`, `Explorer` |
| Funktionen, Variablen | `snake_case` | `counter_tick`, `free_space_offset` | | Funktionen, Variablen | `snake_case` | `human_size`, `line_count` |
| Makros | `UPPER_CASE` mit Projektpräfix | `PLAYGROUND_COUNTER_LIMIT` | | Makros | `UPPER_CASE` mit Projektpräfix | `LIBPDA_TEXTFILE_H`, `PDA_EXPORT` |
| C-API | Modulpräfix | `counter_create`, `counter_free`, `counter_tick` | | C-API | Modulpräfix | `textfile_read`, `textfile_write`, `textfile_escape` |
| Out-Parameter | `out_`-Präfix | `size_t* out_len` | | Out-Parameter | `out_`-Präfix | `size_t* out_len` |
| Member | blank, kein `m_`, kein `_` | `items`, `width` | | Member | blank, kein `m_`, kein `_` | `entries`, `rows`, `here` |
| Namespace | Projektname ohne `lib` | `namespace playground` | | Namespace | Projektname ohne `lib` | `namespace pda` |
## Kommentare ## Kommentare
@@ -95,11 +115,11 @@ grep -rlP '[^\x00-\x7F]' playground tests
## Header ## 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 Öffentliche C-Header bekommen einen `extern "C"`-Block, damit die C++-Seite sie
benutzen kann. 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`. im Header, die Definition in der `.c`.
## Tests ## Tests
@@ -107,7 +127,7 @@ im Header, die Definition in der `.c`.
**Unit-Test** (`<modul>.test.c` / `.test.cpp`) liegt neben dem Modul, kennt **Unit-Test** (`<modul>.test.c` / `.test.cpp`) liegt neben dem Modul, kennt
dessen Interna und ist eine eigenständige Executable. dessen Interna und ist eine eigenständige Executable.
- C → Unity, registriert als CTest `unity.<modul>` (hier: `unity.counter`) - C → Unity, registriert als CTest `unity.<modul>` (hier: `unity.textfile`)
- C++ → doctest, `doctest_discover_tests()` legt **einen CTest-Eintrag pro - C++ → doctest, `doctest_discover_tests()` legt **einen CTest-Eintrag pro
`TEST_CASE`** an `TEST_CASE`** an
@@ -120,6 +140,12 @@ Implementierung) → REFACTOR.
## Build ## 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 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 merkt sonst nicht, dass eine Datei dazugekommen ist, der Build bleibt grün und
die Datei fehlt einfach. die Datei fehlt einfach.
+11 -5
View File
@@ -12,10 +12,15 @@ bereits beantwortet.
## Entscheidung ## Entscheidung
Struktur nach P1204R0, Build- und Stilkonfiguration wörtlich von `mydb` Struktur nach P1204R0, Build- und Stilkonfiguration von `mydb` übernommen
übernommen (`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`, (`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`,
`CMakePresets.json`, `cmake/`), lediglich das Makro- und Target-Präfix von `CMakePresets.json`, `cmake/`), Makro- und Target-Präfix auf `PDA_`/`pda_`
`MYDB_`/`mydb_` auf `PLAYGROUND_`/`playground_` umgestellt. 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 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 `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 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 `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.
+67
View File
@@ -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.
+31
View File
@@ -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")
+39
View File
@@ -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 <cstdio>
#include <string>
#include <libpda/calculator.hpp>
#include <libpda/contact.hpp>
#include <libpda/editor.hpp>
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;
}
+276
View File
@@ -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
# <repo>/libpda, damit <libpda/contact.hpp> auf
# <repo>/libpda/libpda/contact.hpp zeigt.
#
# INSTALL_INTERFACE nachdem installiert wurde. Der Pfad ist relativ zum
# Installationspraefix, also <prefix>/include, und
# <libpda/contact.hpp> zeigt auf
# <prefix>/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
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>"
"$<INSTALL_INTERFACE:include>"
)
# Auch das generierte Export-Header-Verzeichnis muss in beiden Welten stimmen.
target_include_directories(pda PUBLIC
"$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/generated>"
"$<INSTALL_INTERFACE:include>"
)
# Die Warnungen gelten beim Bauen DIESER Bibliothek. Wer sie linkt, soll
# unsere Warnungsliste nicht erben -- fremde Projekte haben ihre eigene.
#
# Warum $<BUILD_INTERFACE:...> 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 $<LINK_ONLY:...>
# 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 "$<BUILD_INTERFACE:pda_warnings>")
# Der C++-Standard als PUBLIC-Anforderung: wer libpda benutzt, braucht
# zwingend C++23, weil contact.hpp <expected> 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 <libpda/pda_export.h> 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: <modul>.test.c -> Executable <modul>.test -> CTest
# "unity.<modul>". 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")
+228
View File
@@ -0,0 +1,228 @@
/* libpda/calculator.cpp
* Tokenizer und Recursive-Descent-Parser.
*/
#include <cmath>
#include <format>
#include <string>
#include <utility>
#include <libpda/calculator.hpp>
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<double, EvalError>
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<EvalError>
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<double, EvalError>
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<double, EvalError>
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<double, EvalError>
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<double, EvalError>
unary()
{
if (consume('-'))
{
auto value = unary();
if (!value) return value;
return -*value;
}
if (consume('+')) return unary();
return primary();
}
std::expected<double, EvalError>
primary()
{
if (consume('('))
{
auto value = expression();
if (!value) return value;
if (!consume(')')) return fail("schliessende Klammer fehlt");
return value;
}
return number();
}
std::expected<double, EvalError>
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<double, EvalError>
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 */
+54
View File
@@ -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 <cstddef>
#include <expected>
#include <string>
#include <string_view>
/* 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 <libpda/pda_export.h>
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<double, EvalError>
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 */
+127
View File
@@ -0,0 +1,127 @@
/* libpda/calculator.test.cpp
* Unit-Tests fuer den Ausdrucksparser (doctest).
*/
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <cmath>
#include <libpda/calculator.hpp>
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:"));
}
+281
View File
@@ -0,0 +1,281 @@
/* libpda/contact.cpp
* Implementierung der Kontaktverwaltung.
*/
#include <algorithm>
#include <cctype>
#include <memory>
#include <string>
#include <utility>
#include <libpda/contact.hpp>
#include <libpda/textfile.h>
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<char, FreeCString>;
[[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<unsigned char> 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<char>(std::tolower(static_cast<unsigned char>(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<std::string>
split_tabs(std::string_view line)
{
std::vector<std::string> 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<Contact>::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<std::size_t>(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<Contact>
ContactBook::search(std::string_view needle) const
{
std::vector<Contact> 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<Contact>&
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<void, ContactError>
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<void, ContactError>
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<TextBlob, void (*)(TextBlob*)> guard{&blob, textfile_blob_free};
const std::string_view content{blob.data, blob.size};
std::vector<Contact> 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<std::string> 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 */
+107
View File
@@ -0,0 +1,107 @@
/* libpda/contact.hpp
* Kontaktverwaltung.
*
* Zeigt die Naht zwischen den Sprachen: die Persistenz laeuft ueber
* <libpda/textfile.h> (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 <cstddef>
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
/* 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 <libpda/pda_export.h>
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<Contact>
search(std::string_view needle) const;
[[nodiscard]] const std::vector<Contact>&
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<void, ContactError>
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<void, ContactError>
load(const std::filesystem::path& path);
private:
std::vector<Contact> entries;
[[nodiscard]] std::vector<Contact>::const_iterator
locate(std::string_view name) const noexcept;
};
} /* namespace pda */
#endif /* LIBPDA_CONTACT_HPP */
+194
View File
@@ -0,0 +1,194 @@
/* libpda/contact.test.cpp
* Unit-Tests fuer die Kontaktverwaltung (doctest).
*/
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <filesystem>
#include <libpda/contact.hpp>
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");
}
+34
View File
@@ -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
* <libpda/details/...> inkludiert, weiss, dass er sich auf Internes stuetzt.
*/
#ifndef LIBPDA_DETAILS_VERSION_HPP
#define LIBPDA_DETAILS_VERSION_HPP
#include <string_view>
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 */
+193
View File
@@ -0,0 +1,193 @@
/* libpda/editor.cpp
* Implementierung des Textpuffers.
*/
#include <memory>
#include <utility>
#include <libpda/editor.hpp>
#include <libpda/textfile.h>
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<std::size_t, EditorError>
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<std::string>&
TextBuffer::lines() const noexcept
{
return rows;
}
std::expected<std::string_view, EditorError>
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<void, EditorError>
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<std::ptrdiff_t>(*index), std::move(text));
dirty = true;
return {};
}
std::expected<void, EditorError>
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<void, EditorError>
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<std::ptrdiff_t>(*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<void, EditorError>
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<TextBlob, void (*)(TextBlob*)> guard{&blob, textfile_blob_free};
std::vector<std::string> 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<void, EditorError>
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 */
+106
View File
@@ -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 <cstddef>
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
/* 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 <libpda/pda_export.h>
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<std::string>&
lines() const noexcept;
/* 1-basiert. line_out_of_range, wenn es die Zeile nicht gibt. */
[[nodiscard]] std::expected<std::string_view, EditorError>
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<void, EditorError>
insert(std::size_t number, std::string text);
[[nodiscard]] std::expected<void, EditorError>
replace(std::size_t number, std::string text);
[[nodiscard]] std::expected<void, EditorError>
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<void, EditorError>
load(const std::filesystem::path& path);
[[nodiscard]] std::expected<void, EditorError>
save(const std::filesystem::path& path);
private:
std::vector<std::string> rows;
bool dirty{false};
/* Prueft eine 1-basierte Zeilennummer und liefert den 0-basierten Index. */
[[nodiscard]] std::expected<std::size_t, EditorError>
index_of(std::size_t number) const noexcept;
};
} /* namespace pda */
#endif /* LIBPDA_EDITOR_HPP */
+174
View File
@@ -0,0 +1,174 @@
/* libpda/editor.test.cpp
* Unit-Tests fuer den Textpuffer (doctest).
*/
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <filesystem>
#include <libpda/editor.hpp>
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");
}
+160
View File
@@ -0,0 +1,160 @@
/* libpda/explorer.cpp
* Implementierung der Verzeichnisnavigation.
*/
#include <algorithm>
#include <array>
#include <format>
#include <system_error>
#include <utility>
#include <libpda/explorer.hpp>
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<std::vector<DirEntry>, 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<DirEntry> 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<void, ExplorerError>
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<void, ExplorerError>
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<std::string_view, 5> units{"B", "K", "M", "G", "T"};
if (bytes < 1024U) return std::format("{}{}", bytes, units[0]);
double value = static_cast<double>(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 */
+85
View File
@@ -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 <cstdint>
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
/* 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 <libpda/pda_export.h>
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<std::vector<DirEntry>, ExplorerError>
list(bool include_hidden = false) const;
/* Wechselt in ein Unterverzeichnis. "-" oder ".." geht nach oben. */
[[nodiscard]] std::expected<void, ExplorerError>
enter(std::string_view name);
/* Nach oben. Im Wurzelverzeichnis passiert nichts (kein Fehler). */
void
up();
/* Absoluter Wechsel. */
[[nodiscard]] std::expected<void, ExplorerError>
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 */
+177
View File
@@ -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 <doctest/doctest.h>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <libpda/explorer.hpp>
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");
}
+252
View File
@@ -0,0 +1,252 @@
/* libpda/textfile.c
* Implementierung von Datei-I/O und Escaping.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libpda/textfile.h>
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);
}
+109
View File
@@ -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 <stddef.h>
/* 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 <libpda/pda_export.h>
#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 */
+178
View File
@@ -0,0 +1,178 @@
/* libpda/textfile.test.c
* Unit-Tests fuer textfile (Unity).
*/
#include <unity.h>
#include <stdio.h>
#include <string.h>
#include <libpda/textfile.h>
/* 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();
}
@@ -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 # Unterschied zu den Unit-Tests neben den Modulen: diese benutzen
# ausschliesslich die OEFFENTLICHE API -- also <playground/...> und sonst # ausschliesslich die OEFFENTLICHE API -- <libpda/...> und sonst nichts. Kein
# nichts. Damit testen sie genau das, was ein Benutzer bekommt, und koennten # doctest, kein Unity, kein Blick in details/.
# im Prinzip auch gegen eine installierte Version laufen.
# #
# 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}") set(target "integration_${name}")
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${name}/driver.c") if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${name}/driver.c")
@@ -21,8 +22,8 @@ function(playground_add_integration_test name)
return() return()
endif() 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}) add_test(NAME "integration.${name}" COMMAND ${target})
endfunction() endfunction()
playground_add_integration_test(basics) libpda_add_integration_test(basics)
+156
View File
@@ -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 <libpda/...> -- 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 <cstdio>
#include <exception>
#include <filesystem>
#include <string>
#include <libpda/calculator.hpp>
#include <libpda/contact.hpp>
#include <libpda/editor.hpp>
#include <libpda/explorer.hpp>
#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;
}
}
+104
View File
@@ -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 <pda/shell.hpp> auf pda/pda/shell.hpp auf.
#
# PUBLIC, weil sowohl die Executable als auch der Test <pda/shell.hpp>
# 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
# <libpda/contact.hpp> 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}")
+128
View File
@@ -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 <kommando...> fuehrt genau ein Kommando aus und beendet sich
*
* Die zweite Form macht das Programm skriptfaehig:
* pda calc "2^10" -> 1024
*/
#include <exception>
#include <filesystem>
#include <iostream>
#include <print>
#include <string>
#include <string_view>
#include <vector>
#include <libpda/details/version.hpp>
#include <pda/shell.hpp>
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;
}
}
+384
View File
@@ -0,0 +1,384 @@
/* pda/shell.cpp
* Implementierung der Kommandoverarbeitung.
*/
#include <format>
#include <utility>
#include <libpda/calculator.hpp>
#include <libpda/details/version.hpp>
#include <pda/shell.hpp>
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 <notiz>). */
[[nodiscard]] std::string
join_from(const std::vector<std::string>& 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<std::string>& args)
{
if (args.size() < 2U) return error(usage("calc <ausdruck>"));
/* 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<std::string>
tokenize(std::string_view line)
{
std::vector<std::string> 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 <ausdruck> rechnet, z.B. calc (2+3)*4 oder calc 2^10
contact add <name> <tel> <mail> [notiz]
contact list
contact find <text> sucht in allen Feldern
contact del <name>
contact save <pfad>
contact load <pfad>
note list Zeilen des Notizpuffers
note add <text>
note del <nr>
note save <pfad>
note load <pfad>
ls [-a] Verzeichnis auflisten (-a auch versteckte)
cd <verzeichnis> ".." 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<std::string> 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<std::string>& args)
{
if (args.size() < 2U) return error(usage("contact <add|list|find|del|save|load> ..."));
const std::string& sub = args[1];
if (sub == "add")
{
if (args.size() < 5U) return error(usage("contact add <name> <tel> <mail> [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 <text>"));
const std::vector<Contact> 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 <name>"));
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 <pfad>"));
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 <pfad>"));
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<std::string>& args)
{
if (args.size() < 2U) return error(usage("note <list|add|del|save|load> ..."));
const std::string& sub = args[1];
if (sub == "add")
{
if (args.size() < 3U) return error(usage("note add <text>"));
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 <nr>"));
/* 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<std::size_t>(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 <pfad>"));
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 <pfad>"));
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<std::string>& 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 <verzeichnis>"));
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 */
+102
View File
@@ -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 <libpda/contact.hpp> Bibliothek
* #include <pda/shell.hpp> 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 <filesystem>
#include <string>
#include <string_view>
#include <vector>
#include <libpda/contact.hpp>
#include <libpda/editor.hpp>
#include <libpda/explorer.hpp>
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<std::string>
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<std::string>& args);
[[nodiscard]] CommandResult
do_note(const std::vector<std::string>& args);
[[nodiscard]] CommandResult
do_files(const std::vector<std::string>& 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 */
+265
View File
@@ -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 <doctest/doctest.h>
#include <filesystem>
#include <string_view>
#include <pda/shell.hpp>
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");
}
+22
View File
@@ -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)
+171
View File
@@ -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 <cstdio>
#include <exception>
#include <filesystem>
#include <string>
#include <vector>
#include <pda/shell.hpp>
#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<std::string> 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;
}
}
-56
View File
@@ -1,56 +0,0 @@
/* playground/counter.c
* Implementierung des saettigenden Zaehlers.
*/
#include <stdlib.h>
#include <playground/counter.h>
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;
}
-48
View File
@@ -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 <stddef.h>
#include <stdint.h>
#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 */
-102
View File
@@ -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 <unity.h>
#include <playground/counter.h>
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();
}
-41
View File
@@ -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 <playground/details/...> 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 <stddef.h>
#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 */
-74
View File
@@ -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 <cstdint>
#include <cstdio>
#include <exception>
#include <string>
#include <playground/counter.h>
#include <playground/notebook.hpp>
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;
}
}
-48
View File
@@ -1,48 +0,0 @@
/* playground/notebook.cpp
* Implementierung des Notizbuchs.
*/
#include <string>
#include <playground/notebook.hpp>
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<std::string_view>
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::Entry>&
Notebook::entries() const noexcept
{
return items;
}
std::size_t
Notebook::size() const noexcept
{
return items.size();
}
void
Notebook::clear() noexcept
{
items.clear();
}
} /* namespace playground */
-60
View File
@@ -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 <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
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<std::string_view>
latest(std::string_view label) const;
const std::vector<Entry>&
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<Entry> items;
};
} /* namespace playground */
#endif /* PLAYGROUND_NOTEBOOK_HPP */
-60
View File
@@ -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 <doctest/doctest.h>
#include <playground/notebook.hpp>
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());
}
-89
View File
@@ -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 <cstdio>
#include <string>
#include <playground/counter.h>
#include <playground/notebook.hpp>
#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;
}
+48
View File
@@ -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."