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