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
# diagnostics you cannot act on. Scoped to this project's own source tree
# instead; third_party/ is a sibling directory and so is excluded by construction.
# Zwei projektspezifische Abschaltungen, begruendet:
#
# -misc-no-recursion Ein Recursive-Descent-Parser IST wechselseitig
# rekursiv (libpda/calculator.cpp). Das ist die
# Bauform, nicht ein Versehen; die Tiefe ist durch
# die Klammertiefe der Eingabe begrenzt.
#
# -performance-enum-size Schlaegt bei JEDEM enum an und will std::uint8_t
# als Basistyp. Gewinn: drei Byte in einem
# Rueckgabewert. Preis: eine ABI-Festlegung und
# Laerm in jeder Datei.
#
# ACHTUNG: Diese Kommentare stehen hier und NICHT im Checks-Block darunter.
# 'Checks: >' ist ein YAML-Folded-Scalar -- darin ist '#' gewoehnlicher Text
# und wuerde als Checkname in die Liste wandern.
Checks: >
bugprone-*,
cert-*,
@@ -20,7 +35,9 @@ Checks: >
-readability-braces-around-statements,
-cert-err33-c,
-misc-include-cleaner,
-readability-implicit-bool-conversion
-readability-implicit-bool-conversion,
-misc-no-recursion,
-performance-enum-size
CheckOptions:
- key: bugprone-easily-swappable-parameters.MinimumLength
+40 -153
View File
@@ -1,180 +1,67 @@
# ---------------------------------------------------------------------------
# playground
# playground - Superprojekt
#
# Struktur nach P1204R0 "Canonical Project Structure":
# playground/playground/ Quellcode UND Header nebeneinander (kein include/ + src/)
# playground/tests/ nur Integrationstests gegen die oeffentliche API
# *.test.c/.cpp Unit-Tests direkt neben dem getesteten Modul
# Dieses Verzeichnis ist KEIN Projekt mit eigenem Quellcode. Es haelt nur zwei
# eigenstaendige Projekte zusammen:
#
# Include-Konvention: eigene Header immer <playground/foo.h>, nie "foo.h".
# Spitze Klammern durchsuchen nur die -I-Pfade, niemals das Verzeichnis der
# inkludierenden Datei -- ein fehlender Header fliegt damit sofort auf, statt
# dass zufaellig ein gleichnamiger lokaler Header gefunden wird.
# libpda/ die Bibliothek -> libpda.a bzw. libpda.dylib
# pda/ die Anwendung -> bin/pda
#
# Extensions: .h/.c fuer C, .hpp/.cpp fuer C++. Die Extension sagt also die
# Sprache -- in einem gemischten Projekt ist das der Punkt.
# P1204R0 verlangt genau diese Trennung ("If a project consists of a library
# and an executable, then they should be split into separate projects"). Der
# Grund ist nicht Ordnungsliebe: die Bibliothek muss OHNE die Anwendung
# baubar, testbar und installierbar sein, sonst ist sie keine Bibliothek,
# sondern ein Unterverzeichnis.
#
# Beide Teilprojekte lassen sich einzeln konfigurieren:
#
# cmake -S libpda -B build/nur-lib && cmake --build build/nur-lib
#
# Das ist die Probe aufs Exempel. Wenn das bricht, hat die Bibliothek eine
# versteckte Abhaengigkeit zur Anwendung.
# ---------------------------------------------------------------------------
# 3.28 = die CMake-Version aus Ubuntu 24.04 LTS. NICHT auf die lokale 4.4
# hochziehen: genau das macht hello-cpp (4.4), und das laesst sich auf keinem
# LTS-Server mehr konfigurieren.
# hochziehen: das laesst sich auf keinem LTS-Server mehr konfigurieren.
cmake_minimum_required(VERSION 3.28)
project(playground
VERSION 0.1.0
DESCRIPTION "Experimentierprojekt nach P1204R0"
DESCRIPTION "PDA: Bibliothek und Anwendung"
LANGUAGES C CXX)
# CMAKE_MODULE_PATH ist die Suchliste fuer include(<name>) und
# find_package(<name> MODULE). Ohne diese Zeile findet include(Warnings)
# nichts.
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# ---------------------------------------------------------------------------
# Sprachstandards: C23 + C++23, keine GNU-Extensions
# ---------------------------------------------------------------------------
set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# ---------------------------------------------------------------------------
# compile_commands.json: HIER, nicht auf der Kommandozeile.
# Muss vor dem ersten Target stehen, sonst bekommt clangd eine veraltete Datei.
# ---------------------------------------------------------------------------
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# C++20-Modul-Scanning aus. Sonst schreibt Ninja bei CXX_STANDARD 23 ein
# "@CMakeFiles/<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(ProjectDefaults)
include(Warnings)
include(Sanitizers)
# ---------------------------------------------------------------------------
# Kernbibliothek - neue Dateien hier manuell eintragen (kein GLOB).
# Optionen. option() legt einen Cache-Eintrag an, den man mit -D ueberschreibt:
#
# GLOB waere bequem, aber CMake merkt nicht, wenn eine Datei dazukommt: der
# Build bleibt gruen und die neue Datei ist einfach nicht dabei.
# cmake --preset debug -DPDA_BUILD_TESTS=OFF
#
# *.test.c und *.test.cpp gehoeren NICHT hierher (P1204R0 Regel 7.1) -- die
# werden unten zu eigenen Executables.
# BUILD_SHARED_LIBS ist eine CMake-Konvention: add_library() ohne STATIC/SHARED
# richtet sich danach. Deshalb hier NICHT selbst erfinden, sondern genau diesen
# Namen benutzen -- jedes Werkzeug da draussen kennt ihn.
# ---------------------------------------------------------------------------
set(PLAYGROUND_SOURCES
playground/counter.c
playground/notebook.cpp
)
option(BUILD_SHARED_LIBS "Bibliothek als Shared Library bauen" OFF)
option(PDA_BUILD_TESTS "Unit- und Integrationstests bauen" ON)
set(PLAYGROUND_HEADERS
playground/counter.h
playground/notebook.hpp
playground/details/bits.h
)
add_library(playground_core STATIC ${PLAYGROUND_SOURCES} ${PLAYGROUND_HEADERS})
# Der Include-Root ist das PROJEKTWURZELVERZEICHNIS, nicht playground/playground.
# Nur so loest <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)
# enable_testing() gehoert ins OBERSTE CMakeLists.txt und nirgendwo sonst.
# Nur hier legt es die CTestTestfile.cmake an, die ctest im Wurzel-Build-
# verzeichnis sucht.
if(PDA_BUILD_TESTS)
enable_testing()
endif()
if(PLAYGROUND_HAVE_UNITY OR PLAYGROUND_HAVE_DOCTEST)
add_subdirectory(third_party)
else()
message(WARNING "third_party/ ist leer -- keine Unit-Test-Targets.")
endif()
# Unit-Test in C: <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)
add_subdirectory(third_party)
add_subdirectory(libpda)
add_subdirectory(pda)
message(STATUS "playground ${PROJECT_VERSION} | C${CMAKE_C_STANDARD} C++${CMAKE_CXX_STANDARD} | "
"${CMAKE_BUILD_TYPE} | ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
"${CMAKE_BUILD_TYPE} | ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION} | "
"shared=${BUILD_SHARED_LIBS} tests=${PDA_BUILD_TESTS}")
+1 -1
View File
@@ -34,7 +34,7 @@
"displayName": "Debug + AddressSanitizer + UBSan",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"PLAYGROUND_SANITIZE": "address,undefined"
"PDA_SANITIZE": "address,undefined"
}
},
+92 -32
View File
@@ -1,32 +1,37 @@
# playground
Experimentierprojekt. Struktur nach
[P1204R0 "Canonical Project Structure"](https://wg21.link/p1204r0), Konventionen
und Build-Setup identisch zu `mydb` und `cxx_scaffold_cli`.
Ein kleiner PDA — Kontakte, Taschenrechner, Notizen, Dateiexplorer — als
Übungsprojekt für die Bau- und Projektstruktur, die dahintersteht.
Der Punkt dieses Repos ist die Form, nicht der Inhalt: `counter` (C) und
`notebook` (C++) sind Gerüst-Module, die jede Regel einmal an lauffähigem Code
zeigen. Zum Experimentieren werden sie ersetzt.
## Layout
Aufgeteilt nach [P1204R0 „Canonical Project
Structure"](https://wg21.link/p1204r0) in **zwei eigenständige Projekte**:
```
playground/
├── playground/ Quellcode UND Header nebeneinander
│ ├── counter.h/.c C-Modul, opakes Struct, extern "C"
│ ├── counter.test.c Unit-Test daneben (Unity)
│ ├── notebook.hpp/.cpp C++-Modul, namespace playground
│ ├── notebook.test.cpp Unit-Test daneben (doctest)
│ ├── main.cpp
│ └── details/ Implementation Details, nicht public API
├── tests/basics/ Integrationstest, nur öffentliche API
├── third_party/ vendortes Unity + doctest, nie editieren
├── cmake/ Warnings.cmake, Sanitizers.cmake
└── docs/ CONVENTIONS.md, ADRs
playground/ Superprojekt, kein eigener Quellcode
├── libpda/ die Bibliothek -> libpda.a
│ ├── libpda/
│ │ ├── textfile.h/.c C: Datei-I/O und Feld-Escaping
│ │ ├── calculator.hpp/.cpp Ausdrucksparser (Recursive Descent)
│ │ ├── contact.hpp/.cpp Kontaktverwaltung mit Persistenz
│ │ ├── editor.hpp/.cpp zeilenorientierter Textpuffer
│ │ ├── explorer.hpp/.cpp Verzeichnisnavigation
│ │ ├── *.test.c/.cpp Unit-Tests, direkt neben dem Modul
│ │ └── details/ Implementation Details
│ └── tests/basics/ Integrationstest, nur öffentliche API
├── pda/ die Anwendung -> bin/pda
│ ├── pda/shell.hpp/.cpp Kommandoverarbeitung, ohne Ein-/Ausgabe
│ ├── pda/main.cpp REPL und Stapelbetrieb
│ └── tests/session/ Integrationstest einer ganzen Sitzung
├── examples/consumer/ fremdes Projekt, benutzt find_package(pda)
├── cmake/ ProjectDefaults, Warnings, Sanitizers, Config-Vorlage
├── third_party/ vendortes Unity + doctest, nie editieren
├── tools/check-install.sh prüft die Export-Kette Ende-zu-Ende
└── docs/ CMAKE.md, CONVENTIONS.md, ADRs
```
Kein `include/` + `src/`. Eigene Header immer `<playground/counter.h>`, nie
`"counter.h"` — das ist P1204R0s wichtigste Einzelregel.
**Warum getrennte Projekte:** P1204R0 verlangt es, und der praktische Grund
ist, dass die Bibliothek ohne die Anwendung baubar, testbar und installierbar
sein muss — sonst ist sie keine Bibliothek, sondern ein Unterverzeichnis.
## Bauen
@@ -34,18 +39,73 @@ Kein `include/` + `src/`. Eigene Header immer `<playground/counter.h>`, nie
cmake --preset debug
cmake --build --preset debug
ctest --preset debug
./build/debug/bin/playground
./build/debug/bin/pda
```
Presets: `debug`, `release`, `asan-ubsan`, `apple-clang`, `homebrew-gcc`,
`linux-gcc`. Details in [docs/CONVENTIONS.md](docs/CONVENTIONS.md).
`linux-gcc`.
## Ein neues Modul hinzufügen
## Benutzen
1. `playground/<name>.hpp` + `.cpp` anlegen (bzw. `.h`/`.c` für C).
2. Beide in `CMakeLists.txt` bei `PLAYGROUND_SOURCES` / `PLAYGROUND_HEADERS`
eintragen — **kein `file(GLOB)`**, sonst bleibt der Build grün und die Datei
fehlt einfach.
3. `playground/<name>.test.cpp` daneben, dann
`playground_add_doctest_test(<name>)` aufrufen (C: `.test.c` und
`playground_add_unity_test(<name>)`).
Interaktiv:
```
$ pda
pda 0.1.0 -- 'help' zeigt die Kommandos, 'quit' beendet.
playground> contact add "Anna Schmidt" 0151 anna@example.org meine Schwester
'Anna Schmidt' angelegt (1 Kontakte)
playground> calc (2+3)*4 - 10/2
15
playground> note add Anna anrufen
Zeile 1 angelegt
playground> contact save kontakte.tsv
1 Kontakte nach kontakte.tsv geschrieben
```
Im Stapelbetrieb — ein Kommando, dann Ende:
```sh
pda calc "2^10" # 1024
pda contact list
```
Der Exit-Code ist 1, wenn das Kommando scheitert. Fehler gehen nach stderr.
## Als Bibliothek benutzen
```sh
cmake -S libpda -B build/lib -DCMAKE_INSTALL_PREFIX=/pfad
cmake --build build/lib && cmake --install build/lib
```
Dann im eigenen Projekt:
```cmake
find_package(pda 0.1 REQUIRED)
target_link_libraries(mein_programm PRIVATE pda::pda)
```
```cpp
#include <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).
#
# Warum das gerade hier zaehlt: in einem Playground steht oft genau der Code,
@@ -8,17 +8,19 @@
# assert-Test sieht das nie, ASan sofort.
# ---------------------------------------------------------------------------
set(PLAYGROUND_SANITIZE "" CACHE STRING
include_guard(GLOBAL)
set(PDA_SANITIZE "" CACHE STRING
"Komma-getrennte Sanitizer-Liste, z.B. address,undefined")
if(NOT PLAYGROUND_SANITIZE STREQUAL "")
if(NOT PDA_SANITIZE STREQUAL "")
if(NOT (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang"))
message(WARNING "PLAYGROUND_SANITIZE wird von ${CMAKE_CXX_COMPILER_ID} nicht unterstuetzt")
message(WARNING "PDA_SANITIZE wird von ${CMAKE_CXX_COMPILER_ID} nicht unterstuetzt")
return()
endif()
set(_flags
-fsanitize=${PLAYGROUND_SANITIZE}
-fsanitize=${PDA_SANITIZE}
# Ohne Frame-Pointer sind die ASan-Stacks unbrauchbar.
-fno-omit-frame-pointer
@@ -27,7 +29,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "")
-fno-optimize-sibling-calls
)
if(PLAYGROUND_SANITIZE MATCHES "undefined")
if(PDA_SANITIZE MATCHES "undefined")
# Entscheidend: ohne das MELDET UBSan den Fehler nur und laeuft weiter,
# der Test wird gruen und die Meldung verschwindet im Scrollback.
# So schlaegt der Test tatsaechlich fehl.
@@ -35,7 +37,7 @@ if(NOT PLAYGROUND_SANITIZE STREQUAL "")
endif()
add_compile_options(${_flags})
add_link_options(-fsanitize=${PLAYGROUND_SANITIZE})
add_link_options(-fsanitize=${PDA_SANITIZE})
message(STATUS "Sanitizers: ${PLAYGROUND_SANITIZE}")
message(STATUS "Sanitizers: ${PDA_SANITIZE}")
endif()
+12 -7
View File
@@ -10,9 +10,14 @@
# live in Neovim angezeigt, weil clangd sie ohnehin liefert.
# ---------------------------------------------------------------------------
add_library(playground_warnings INTERFACE)
# Die Datei kann aus zwei verschiedenen Projekten eingebunden werden
# (Superprojekt und libpda im Alleinbetrieb). Ohne include_guard waere der
# zweite add_library()-Aufruf ein harter Fehler: "target already exists".
include_guard(GLOBAL)
set(PLAYGROUND_GCC_LIKE_WARNINGS
add_library(pda_warnings INTERFACE)
set(PDA_GCC_LIKE_WARNINGS
-Wall
-Wextra
-Wpedantic
@@ -44,7 +49,7 @@ set(PLAYGROUND_GCC_LIKE_WARNINGS
# rohen Puffern experimentiert, sichert die Ausrichtung im Code zu
# (alignas(...)), wo sie ueberpruefbar ist.
set(PLAYGROUND_C_ONLY_WARNINGS
set(PDA_C_ONLY_WARNINGS
# Ein () statt (void) in C ist eine Funktion mit unbekannten Parametern --
# in C23 zwar geheilt, aber die Warnung haelt aelteren Code ehrlich.
-Wstrict-prototypes
@@ -52,13 +57,13 @@ set(PLAYGROUND_C_ONLY_WARNINGS
-Wold-style-definition
)
set(PLAYGROUND_CXX_ONLY_WARNINGS
set(PDA_CXX_ONLY_WARNINGS
-Wnon-virtual-dtor
-Woverloaded-virtual
-Wold-style-cast
)
target_compile_options(playground_warnings INTERFACE
$<$<COMPILE_LANG_AND_ID:C,GNU,Clang,AppleClang>:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_C_ONLY_WARNINGS}>
$<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:${PLAYGROUND_GCC_LIKE_WARNINGS};${PLAYGROUND_CXX_ONLY_WARNINGS}>
target_compile_options(pda_warnings INTERFACE
$<$<COMPILE_LANG_AND_ID:C,GNU,Clang,AppleClang>:${PDA_GCC_LIKE_WARNINGS};${PDA_C_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)
```
playground/
├── playground/ <- Quellcode UND Header zusammen
│ ├── counter.h public API (C)
│ ├── counter.c
│ ├── counter.test.c Unit-Test, direkt neben dem Modul
│ ├── notebook.hpp public API (C++)
│ ├── notebook.cpp
│ ├── notebook.test.cpp
│ ├── main.cpp
│ └── details/ Implementation Details, nicht public API
├── tests/basics/ Integrationstests gegen die öffentliche API
├── third_party/ vendorter Fremdcode, nie editieren
├── cmake/ Warnings.cmake, Sanitizers.cmake
└── docs/ diese Datei, ADRs
```
Zwei eigenständige Projekte unter einem Superprojekt:
`counter` und `notebook` sind Gerüst: sie zeigen jede Regel einmal an einem
lauffähigen Beispiel und sind dazu da, ersetzt zu werden. Was bleiben soll,
ist die Form, nicht der Inhalt.
```
playground/ Superprojekt, kein eigener Quellcode
├── libpda/ project(libpda) -> libpda.a
│ ├── libpda/ Quellcode UND Header zusammen
│ │ ├── textfile.h public API (C)
│ │ ├── textfile.c
│ │ ├── textfile.test.c Unit-Test, direkt neben dem Modul
│ │ ├── contact.hpp public API (C++)
│ │ ├── contact.cpp
│ │ ├── contact.test.cpp
│ │ └── details/ Implementation Details, nicht public API
│ └── tests/basics/ Integrationstests gegen die öffentliche API
├── pda/ project(pda) -> bin/pda
│ ├── pda/ shell.hpp/.cpp, main.cpp
│ └── tests/session/
├── examples/consumer/ fremdes Projekt, find_package(pda)
├── third_party/ vendorter Fremdcode, nie editieren
├── cmake/ ProjectDefaults, Warnings, Sanitizers, pdaConfig.cmake.in
└── docs/ CMAKE.md, diese Datei, ADRs
```
**Kein `include/` + `src/`.** Header und Implementierung liegen nebeneinander.
Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber
zu ziehen, und der Verzeichnisname `playground/` übernimmt die Rolle des Namespace
im Dateisystem.
Bei Templates, `inline` und Modulen ist die Trennung ohnehin nicht sauber zu
ziehen, und der Verzeichnisname übernimmt die Rolle des Namespace im
Dateisystem.
**Bibliothek und Programm sind getrennte Projekte.** P1204R0 verlangt das; der
praktische Grund ist, dass `libpda` ohne `pda` baubar sein muss. Die Probe:
```sh
cmake -S libpda -B build/nur-lib && cmake --build build/nur-lib
```
**Ein Namespace, zwei Include-Wurzeln.** Beide Projekte benutzen
`namespace pda` — auseinandergehalten wird über den Pfad:
```cpp
#include <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
**Eigene Header immer mit spitzen Klammern und Projektpräfix:**
```c
#include <playground/counter.h> /* richtig */
#include "counter.h" /* falsch */
#include <libpda/textfile.h> /* richtig */
#include "textfile.h" /* falsch */
```
Das ist P1204R0s wichtigste Einzelregel. Spitze Klammern durchsuchen nur die
@@ -68,13 +88,13 @@ ein `.hpp` nur aus C++.
| Was | Form | Beispiel |
|---|---|---|
| Typen | `PascalCase` | `Counter`, `Notebook` |
| Funktionen, Variablen | `snake_case` | `counter_tick`, `free_space_offset` |
| Makros | `UPPER_CASE` mit Projektpräfix | `PLAYGROUND_COUNTER_LIMIT` |
| C-API | Modulpräfix | `counter_create`, `counter_free`, `counter_tick` |
| Typen | `PascalCase` | `ContactBook`, `TextBuffer`, `Explorer` |
| Funktionen, Variablen | `snake_case` | `human_size`, `line_count` |
| Makros | `UPPER_CASE` mit Projektpräfix | `LIBPDA_TEXTFILE_H`, `PDA_EXPORT` |
| C-API | Modulpräfix | `textfile_read`, `textfile_write`, `textfile_escape` |
| Out-Parameter | `out_`-Präfix | `size_t* out_len` |
| Member | blank, kein `m_`, kein `_` | `items`, `width` |
| Namespace | Projektname ohne `lib` | `namespace playground` |
| Member | blank, kein `m_`, kein `_` | `entries`, `rows`, `here` |
| Namespace | Projektname ohne `lib` | `namespace pda` |
## Kommentare
@@ -95,11 +115,11 @@ grep -rlP '[^\x00-\x7F]' playground tests
## Header
`#ifndef PLAYGROUND_COUNTER_H` — Include-Guards, kein `#pragma once`.
`#ifndef LIBPDA_TEXTFILE_H` — Include-Guards, kein `#pragma once`.
Öffentliche C-Header bekommen einen `extern "C"`-Block, damit die C++-Seite sie
benutzen kann.
Structs, deren Layout niemanden angeht, sind opak: `typedef struct Counter Counter;`
Structs, deren Layout niemanden angeht, sind opak: `typedef struct Page Page;`
im Header, die Definition in der `.c`.
## Tests
@@ -107,7 +127,7 @@ im Header, die Definition in der `.c`.
**Unit-Test** (`<modul>.test.c` / `.test.cpp`) liegt neben dem Modul, kennt
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
`TEST_CASE`** an
@@ -120,6 +140,12 @@ Implementierung) → REFACTOR.
## Build
Die ausführliche Erklärung steht in [CMAKE.md](CMAKE.md). Das Wichtigste:
- Öffentliche Deklarationen der Bibliothek brauchen `PDA_EXPORT`, sonst
exportiert der Shared-Bau sie nicht.
- Neue Dateien immer von Hand in `LIBPDA_SOURCES` / `LIBPDA_HEADERS`.
Kein `file(GLOB)`. Neue Dateien werden in `CMakeLists.txt` eingetragen — CMake
merkt sonst nicht, dass eine Datei dazugekommen ist, der Build bleibt grün und
die Datei fehlt einfach.
+11 -5
View File
@@ -12,10 +12,15 @@ bereits beantwortet.
## Entscheidung
Struktur nach P1204R0, Build- und Stilkonfiguration wörtlich von `mydb`
übernommen (`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`,
`CMakePresets.json`, `cmake/`), lediglich das Makro- und Target-Präfix von
`MYDB_`/`mydb_` auf `PLAYGROUND_`/`playground_` umgestellt.
Struktur nach P1204R0, Build- und Stilkonfiguration von `mydb` übernommen
(`.clang-format`, `.clang-tidy`, `.clangd`, `.editorconfig`,
`CMakePresets.json`, `cmake/`), Makro- und Target-Präfix auf `PDA_`/`pda_`
umgestellt.
Zwei Abweichungen vom Hausstandard, beide in [0002](0002-pda-aufteilung.md)
begründet: die Aufteilung in zwei Projekte und das daraus folgende
`cmake/ProjectDefaults.cmake`, das die Grundeinstellungen aus dem
`CMakeLists.txt` herauszieht, damit beide Teilprojekte sie teilen können.
Die vendorten Abhängigkeiten (Unity 2.7.0, doctest 2.5.3) wurden aus
`mydb/third_party/` kopiert statt neu geholt — damit sind die Versionen über
@@ -32,4 +37,5 @@ ist der Ort, an dem eine gemeinsame Vorlage entstehen müsste.
Offen bleibt, dass `docs/CONVENTIONS.md` in `mydb` ein `check_style.sh` und ein
`tools/vendor.sh` beschreibt, die in keinem der Repos existieren. In der
playground-Fassung sind diese Verweise entfernt.
playground-Fassung sind diese Verweise entfernt; `tools/` enthält hier
stattdessen `check-install.sh`, das tatsächlich existiert und läuft.
+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
# ausschliesslich die OEFFENTLICHE API -- also <playground/...> und sonst
# nichts. Damit testen sie genau das, was ein Benutzer bekommt, und koennten
# im Prinzip auch gegen eine installierte Version laufen.
# Unterschied zu den Unit-Tests neben den Modulen: diese benutzen
# ausschliesslich die OEFFENTLICHE API -- <libpda/...> und sonst nichts. Kein
# doctest, kein Unity, kein Blick in details/.
#
# Jedes Unterverzeichnis ist ein eigener Testfall mit eigener driver.c/.cpp.
# Der Grund fuer das eigene Verzeichnis steht in P1204R0: solche Tests sollen
# auch gegen eine INSTALLIERTE Bibliothek laufen koennen. Was hier gruen ist,
# ist gruen fuer einen Benutzer.
# ---------------------------------------------------------------------------
function(playground_add_integration_test name)
function(libpda_add_integration_test name)
set(target "integration_${name}")
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${name}/driver.c")
@@ -21,8 +22,8 @@ function(playground_add_integration_test name)
return()
endif()
target_link_libraries(${target} PRIVATE playground_core playground_warnings)
target_link_libraries(${target} PRIVATE pda::pda pda_warnings)
add_test(NAME "integration.${name}" COMMAND ${target})
endfunction()
playground_add_integration_test(basics)
libpda_add_integration_test(basics)
+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."