diff --git a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md index 036ac63..56bc83f 100644 --- a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md +++ b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md @@ -189,3 +189,52 @@ - handoff: backend-free `EntityIndex`, semantic model records, `ModelDefinition`, and immutable `Domain::create`/const accessors are available to Step 11 syntax parsing and Step 12 semantic mapping. + +## Step 11 — inp-syntax-parser + +- task_id: `TASK-11` +- status: `completed` +- changed_files: `include/fesa/io/abaqus/input_syntax.hpp`, + `include/fesa/io/abaqus/input_reader.hpp`, + `src/fesa/io/abaqus/input_reader.cpp`, + `tests/unit/io/abaqus/input_syntax_test.cpp`, + `tests/unit/io/abaqus/input_reader_test.cpp`, `src/fesa/CMakeLists.txt`, + `tests/CMakeLists.txt`, + `docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md`, + `phases/linear-static-3d-euler-beam/index.json`, + `.superpowers/sdd/linear-static-3d-euler-beam/task-11-report.md` +- requirement_ids: `FESA-REQ-LS3DEB-010`, `FESA-REQ-LS3DEB-034`, + `FESA-REQ-LS3DEB-040` +- test_ids: `T11-SYN-001`, `T11-SYN-002`, `T11-SYN-003`, `T11-SYN-004` + +| stage | exact command | exit_code | expected_or_observed_result | evidence_tail | +| --- | --- | ---: | --- | --- | +| RED | `cmake --build .harness/build --config Debug --target fesa_tests`; `ctest --test-dir .harness/build -C Debug -R InpSyntax --output-on-failure` | 1 | Both planned test files are registered before production and fail for the missing reader API | MSVC C1083 for `fesa/io/abaqus/input_reader.hpp` in both new test translation units; CTest subsequently reported `No tests were found` because the executable could not build | +| GREEN-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 0 | Minimal syntax records, binary reader, and four tests compile and link | `input_reader.cpp`, both parser test files, `fesa_solver.lib`, and `fesa_unit_tests.exe` built without a FESA warning under `/W4 /WX` | +| GREEN-test | `ctest --test-dir .harness/build -C Debug -R InpSyntax --output-on-failure` | 0 | All exact syntax parsing behaviors pass | 4/4 `InpSyntax` tests passed | +| VERIFY-configure | `cmake -S . -B .harness/build -A x64 -DFESA_GTEST_SOURCE_DIR=C:/git/googletest "-DMKL_DIR=C:/Program Files (x86)/Intel/oneAPI/mkl/2026.1/lib/cmake/mkl" "-DTBB_DIR=C:/Program Files (x86)/Intel/oneAPI/tbb/2023.1/lib/cmake/tbb" "-DHDF5_DIR=C:/Program Files/HDF_Group/HDF5/2.1.1/cmake"` | 0 | Approved MSVC x64 build tree regenerates with explicit dependencies | Visual Studio 18 2026/MSVC environment selected; configure and generate completed; oneMKL 2026.1 resolved | +| VERIFY-build | `cmake --build .harness/build --config Debug` | 0 | Full Debug build passes without a new FESA warning | `fesa_solver.lib` and `fesa_unit_tests.exe` built under `/W4 /WX` | +| VERIFY-targeted | `ctest --test-dir .harness/build -C Debug -R InpSyntax --output-on-failure` | 0 | Focused Step 11 suite remains green | 4/4 exact `InpSyntax` tests passed | +| VERIFY-discovery | `ctest --test-dir .harness/build -C Debug --show-only=json-v1` | 0 | CTest discovers the accumulated suite and all four exact syntax tests | 14 tests discovered with all four `InpSyntax` names and feature/unit labels | +| VERIFY-full | `ctest --test-dir .harness/build -C Debug --output-on-failure` | 0 | Full accumulated C++ suite has zero failures | 14/14 tests passed | +| VERIFY-dependency-direction | Backend, upward I/O, core-to-I/O, and semantic-policy scans over the Step 11 public/production files using `rg -n` fail-on-match wrappers | 0 | Public headers retain backend-free `core -> io/abaqus` direction and syntax parsing contains no Step 12 policy | backend leaks 0; upward I/O dependencies 0; core-to-I/O dependencies 0; semantic-policy matches 0; exactly four `InpSyntax` tests found | +| VERIFY-diff | `git diff --check` | 0 | Patch has no whitespace errors | Exit 0; only informational Git LF-to-CRLF working-copy notices were emitted | +| VERIFY-reference | `git diff --exit-code -- reference/`; `git status --short -- reference/` | 0 | Approved legacy reference artifacts remain unchanged | Diff exit 0 and reference status empty | + +- contract_checks: `ParsedInput` records the absolute lexically normalized + source path and exact binary-byte FNV-1a identity formatted as + `fnv1a64:` plus 16 lowercase hexadecimal digits. Keyword and parameter names + alone become ASCII uppercase; parameter values, data label lexemes, empty + and trailing data fields, and each keyword `originalLine` preserve source + spelling. Comments and blanks remain excluded while physical 1-based file + line accounting is retained. Unreadable file, empty keyword, and orphan data + return categorized input diagnostics. No keyword allowlist, wrapper/nesting, + numeric conversion, B31/B33, or output-request decision exists in this Step. + The read-only legacy input parses as 30 keyword blocks with exact identity + `fnv1a64:04543464cc970405`, unchanged bytes, and unchanged modification time. +- generated_evidence: `.harness/build/src/fesa/Debug/fesa_solver.lib`, + `.harness/build/tests/Debug/fesa_unit_tests.exe` +- reference_diff: unchanged; `git diff --exit-code -- reference/` exit 0 +- handoff: backend-free `KeywordParameter`, `DataLine`, `KeywordBlock`, + `ParsedInput`, and `AbaqusInputReader::read` with syntax/source provenance are + available to Step 12 semantic mapping. diff --git a/include/fesa/io/abaqus/input_reader.hpp b/include/fesa/io/abaqus/input_reader.hpp new file mode 100644 index 0000000..04fe67b --- /dev/null +++ b/include/fesa/io/abaqus/input_reader.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "fesa/core/status.hpp" +#include "fesa/io/abaqus/input_syntax.hpp" + +#include + +namespace fesa { + +// Reads only physical keyword/data/comment syntax; semantic policy is applied +// later by AbaqusDomainMapper. +class AbaqusInputReader { +public: + Result read(const std::filesystem::path& inputPath) const; +}; + +} // namespace fesa diff --git a/include/fesa/io/abaqus/input_syntax.hpp b/include/fesa/io/abaqus/input_syntax.hpp new file mode 100644 index 0000000..163be25 --- /dev/null +++ b/include/fesa/io/abaqus/input_syntax.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "fesa/core/source_identity.hpp" + +#include +#include +#include +#include + +namespace fesa { + +// Names are canonicalized for syntax lookup while values remain source text. +struct KeywordParameter { + std::string name; + std::optional value; +}; + +struct DataLine { + std::vector fields; + SourceLocation location; +}; + +struct KeywordBlock { + std::string canonicalName; + std::string originalLine; + std::vector parameters; + std::vector data; + SourceLocation location; +}; + +struct ParsedInput { + std::filesystem::path sourcePath; + std::string sourceContentIdentity; + std::vector blocks; +}; + +} // namespace fesa diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 9568d7e..955c141 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -4,6 +4,7 @@ add_library( build_info.cpp core/diagnostic.cpp core/status.cpp + io/abaqus/input_reader.cpp math/matrix.cpp math/vector.cpp model/domain.cpp diff --git a/src/fesa/io/abaqus/input_reader.cpp b/src/fesa/io/abaqus/input_reader.cpp new file mode 100644 index 0000000..c932a5e --- /dev/null +++ b/src/fesa/io/abaqus/input_reader.cpp @@ -0,0 +1,216 @@ +#include "fesa/io/abaqus/input_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +std::filesystem::path normalizedPath(const std::filesystem::path& path) { + std::error_code error; + const auto absolute = std::filesystem::absolute(path, error); + return (error ? path : absolute).lexically_normal(); +} + +bool isAsciiWhitespace(char value) noexcept { + return value == ' ' || value == '\t' || value == '\r' || + value == '\n' || value == '\f' || value == '\v'; +} + +std::string trim(std::string_view text) { + while (!text.empty() && isAsciiWhitespace(text.front())) { + text.remove_prefix(1U); + } + while (!text.empty() && isAsciiWhitespace(text.back())) { + text.remove_suffix(1U); + } + return std::string{text}; +} + +std::string uppercaseAscii(std::string value) { + std::transform( + value.begin(), value.end(), value.begin(), [](char character) { + if (character >= 'a' && character <= 'z') { + return static_cast(character - 'a' + 'A'); + } + return character; + }); + return value; +} + +std::vector splitFields(std::string_view line) { + std::vector fields; + std::size_t fieldStart = 0U; + while (true) { + const std::size_t separator = line.find(',', fieldStart); + if (separator == std::string_view::npos) { + fields.push_back(trim(line.substr(fieldStart))); + break; + } + fields.push_back(trim(line.substr(fieldStart, separator - fieldStart))); + fieldStart = separator + 1U; + } + return fields; +} + +std::string contentIdentity(const std::string& bytes) { + constexpr std::uint64_t offsetBasis = 14695981039346656037ULL; + constexpr std::uint64_t prime = 1099511628211ULL; + std::uint64_t hash = offsetBasis; + // Hash the binary input before CRLF handling so provenance follows the + // exact file bytes rather than a normalized text representation. + for (const unsigned char byte : bytes) { + hash ^= static_cast(byte); + hash *= prime; + } + + std::ostringstream formatted; + formatted << "fnv1a64:" << std::hex << std::setfill('0') + << std::setw(16) << hash; + return formatted.str(); +} + +Result failure( + const std::filesystem::path& sourcePath, + std::size_t line, + std::string code, + std::string keyword, + std::string message) { + Diagnostic diagnostic{ + Severity::error, + std::move(code), + {sourcePath, line}, + std::move(keyword), + "", + std::move(message)}; + return Result::failure(Status::failure( + FailureCategory::input, {std::move(diagnostic)})); +} + +} // namespace + +Result AbaqusInputReader::read( + const std::filesystem::path& inputPath) const { + const auto sourcePath = normalizedPath(inputPath); + std::ifstream stream{sourcePath, std::ios::binary}; + if (!stream) { + return failure( + sourcePath, + 0U, + "input-file-unreadable", + "", + "The Abaqus input file could not be opened for reading."); + } + + const std::string bytes{ + std::istreambuf_iterator{stream}, + std::istreambuf_iterator{}}; + if (stream.bad()) { + return failure( + sourcePath, + 0U, + "input-file-unreadable", + "", + "The Abaqus input file could not be read completely."); + } + + ParsedInput parsed{sourcePath, contentIdentity(bytes), {}}; + std::size_t lineStart = 0U; + std::size_t lineNumber = 1U; + while (lineStart < bytes.size()) { + const std::size_t newline = bytes.find('\n', lineStart); + const std::size_t lineEnd = + newline == std::string::npos ? bytes.size() : newline; + std::string originalLine = bytes.substr(lineStart, lineEnd - lineStart); + if (!originalLine.empty() && originalLine.back() == '\r') { + originalLine.pop_back(); + } + + const std::string trimmedLine = trim(originalLine); + if (!trimmedLine.empty() && trimmedLine.rfind("**", 0U) != 0U) { + if (trimmedLine.front() == '*') { + const auto fields = splitFields(trimmedLine); + const std::string keywordText = + fields.empty() ? std::string{} : trim( + std::string_view{fields[0]}.substr(1U)); + if (keywordText.empty()) { + return failure( + sourcePath, + lineNumber, + "malformed-keyword", + trimmedLine, + "A keyword line requires a non-empty keyword name."); + } + + KeywordBlock block{ + uppercaseAscii(keywordText), + originalLine, + {}, + {}, + {sourcePath, lineNumber}}; + for (std::size_t index = 1U; index < fields.size(); ++index) { + const std::string& field = fields[index]; + if (field.empty()) { + return failure( + sourcePath, + lineNumber, + "malformed-keyword", + block.canonicalName, + "A keyword parameter name cannot be empty."); + } + + const std::size_t equals = field.find('='); + const std::string parameterName = trim(std::string_view{field}.substr( + 0U, equals)); + if (parameterName.empty()) { + return failure( + sourcePath, + lineNumber, + "malformed-keyword", + block.canonicalName, + "A keyword parameter name cannot be empty."); + } + + KeywordParameter parameter{ + uppercaseAscii(parameterName), std::nullopt}; + if (equals != std::string::npos) { + parameter.value = trim( + std::string_view{field}.substr(equals + 1U)); + } + block.parameters.push_back(std::move(parameter)); + } + parsed.blocks.push_back(std::move(block)); + } else { + if (parsed.blocks.empty()) { + return failure( + sourcePath, + lineNumber, + "orphan-data-line", + "", + "A data line must follow a keyword line."); + } + parsed.blocks.back().data.push_back( + {splitFields(originalLine), {sourcePath, lineNumber}}); + } + } + + if (newline == std::string::npos) { + break; + } + lineStart = newline + 1U; + ++lineNumber; + } + + return Result::success(std::move(parsed)); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 84c5473..1c9c394 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,6 +8,8 @@ add_executable( unit/core/status_test.cpp unit/math/matrix_test.cpp unit/math/vector_test.cpp + unit/io/abaqus/input_reader_test.cpp + unit/io/abaqus/input_syntax_test.cpp unit/model/domain_test.cpp unit/model/model_types_test.cpp ) diff --git a/tests/unit/io/abaqus/input_reader_test.cpp b/tests/unit/io/abaqus/input_reader_test.cpp new file mode 100644 index 0000000..7304f99 --- /dev/null +++ b/tests/unit/io/abaqus/input_reader_test.cpp @@ -0,0 +1,133 @@ +#include "fesa/io/abaqus/input_reader.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +class TemporaryInputFile { +public: + TemporaryInputFile(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create INP reader test fixture."}; + } + } + + ~TemporaryInputFile() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +std::string readExactBytes(const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + throw std::runtime_error{"Unable to read legacy INP fixture."}; + } + return std::string{ + std::istreambuf_iterator{stream}, + std::istreambuf_iterator{}}; +} + +std::filesystem::path repositoryRoot() { + auto path = std::filesystem::path{__FILE__}.parent_path(); + for (int parent = 0; parent < 4; ++parent) { + path = path.parent_path(); + } + return path; +} + +} // namespace + +TEST(InpSyntax, RejectsMalformedOrOrphanData) { + const auto missingPath = + std::filesystem::temp_directory_path() / "fesa-missing-input.inp"; + std::error_code removeError; + std::filesystem::remove(missingPath, removeError); + + const auto unreadable = fesa::AbaqusInputReader{}.read(missingPath); + ASSERT_FALSE(unreadable.hasValue()); + EXPECT_EQ( + unreadable.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_EQ(unreadable.status().diagnostics().size(), 1U); + EXPECT_EQ(unreadable.status().diagnostics()[0].code, + "input-file-unreadable"); + + const TemporaryInputFile malformed{"malformed-keyword", "*, name=value\n"}; + const auto malformedResult = + fesa::AbaqusInputReader{}.read(malformed.path()); + ASSERT_FALSE(malformedResult.hasValue()); + EXPECT_EQ( + malformedResult.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_EQ(malformedResult.status().diagnostics().size(), 1U); + EXPECT_EQ(malformedResult.status().diagnostics()[0].code, + "malformed-keyword"); + EXPECT_EQ(malformedResult.status().diagnostics()[0].location.line, 1U); + + const TemporaryInputFile orphan{ + "orphan-data", "** comment\n\norphan, data\n"}; + const auto orphanResult = fesa::AbaqusInputReader{}.read(orphan.path()); + ASSERT_FALSE(orphanResult.hasValue()); + EXPECT_EQ( + orphanResult.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_EQ(orphanResult.status().diagnostics().size(), 1U); + EXPECT_EQ(orphanResult.status().diagnostics()[0].code, + "orphan-data-line"); + EXPECT_EQ(orphanResult.status().diagnostics()[0].location.line, 3U); +} + +TEST(InpSyntax, ReadsLegacyCantileverWithoutMutation) { + const auto inputPath = + repositoryRoot() / "reference" / "cantilever beam" / + "cantilever beam.inp"; + const auto bytesBefore = readExactBytes(inputPath); + const auto timestampBefore = std::filesystem::last_write_time(inputPath); + + const auto result = fesa::AbaqusInputReader{}.read(inputPath); + + ASSERT_TRUE(result.hasValue()); + EXPECT_EQ(result.value().sourceContentIdentity, + "fnv1a64:04543464cc970405"); + EXPECT_EQ(result.value().sourcePath, + std::filesystem::absolute(inputPath).lexically_normal()); + ASSERT_EQ(result.value().blocks.size(), 30U); + EXPECT_EQ(result.value().blocks.front().canonicalName, "HEADING"); + EXPECT_EQ(result.value().blocks.front().location.line, 1U); + EXPECT_EQ(result.value().blocks.back().canonicalName, "END STEP"); + + const auto element = std::find_if( + result.value().blocks.begin(), + result.value().blocks.end(), + [](const fesa::KeywordBlock& block) { + return block.canonicalName == "ELEMENT"; + }); + ASSERT_NE(element, result.value().blocks.end()); + ASSERT_EQ(element->parameters.size(), 1U); + EXPECT_EQ(element->parameters[0].name, "TYPE"); + ASSERT_TRUE(element->parameters[0].value.has_value()); + EXPECT_EQ(*element->parameters[0].value, "B33"); + EXPECT_EQ(element->data.size(), 10U); + + EXPECT_EQ(readExactBytes(inputPath), bytesBefore); + EXPECT_EQ(std::filesystem::last_write_time(inputPath), timestampBefore); +} diff --git a/tests/unit/io/abaqus/input_syntax_test.cpp b/tests/unit/io/abaqus/input_syntax_test.cpp new file mode 100644 index 0000000..618750b --- /dev/null +++ b/tests/unit/io/abaqus/input_syntax_test.cpp @@ -0,0 +1,95 @@ +#include "fesa/io/abaqus/input_reader.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace { + +class TemporaryInputFile { +public: + TemporaryInputFile(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create INP syntax test fixture."}; + } + } + + ~TemporaryInputFile() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +} // namespace + +TEST(InpSyntax, CanonicalizesKeywordAndParameterNamesOnly) { + const std::string originalLine = + " *eLeMeNt, TyPe= b33 , generate, ELSET=Beam_Set "; + const TemporaryInputFile input{ + "canonical-names", originalLine + "\n"}; + + const auto result = fesa::AbaqusInputReader{}.read(input.path()); + + ASSERT_TRUE(result.hasValue()); + ASSERT_EQ(result.value().blocks.size(), 1U); + const auto& block = result.value().blocks[0]; + EXPECT_EQ(block.canonicalName, "ELEMENT"); + EXPECT_EQ(block.originalLine, originalLine); + EXPECT_EQ(block.location.line, 1U); + + ASSERT_EQ(block.parameters.size(), 3U); + EXPECT_EQ(block.parameters[0].name, "TYPE"); + ASSERT_TRUE(block.parameters[0].value.has_value()); + EXPECT_EQ(*block.parameters[0].value, "b33"); + EXPECT_EQ(block.parameters[1].name, "GENERATE"); + EXPECT_FALSE(block.parameters[1].value.has_value()); + EXPECT_EQ(block.parameters[2].name, "ELSET"); + ASSERT_TRUE(block.parameters[2].value.has_value()); + EXPECT_EQ(*block.parameters[2].value, "Beam_Set"); +} + +TEST(InpSyntax, PreservesDataAndSourceLocations) { + const std::string exactBytes = + "** retained only in line accounting\r\n" + "\r\n" + "*NoDe\r\n" + " 0007, Label_A, ,\r\n"; + const TemporaryInputFile input{"data-and-locations", exactBytes}; + + const auto result = fesa::AbaqusInputReader{}.read(input.path()); + + ASSERT_TRUE(result.hasValue()); + EXPECT_EQ( + result.value().sourcePath, + std::filesystem::absolute(input.path()).lexically_normal()); + EXPECT_EQ(result.value().sourceContentIdentity, + "fnv1a64:c120b6ed2445be46"); + ASSERT_EQ(result.value().blocks.size(), 1U); + const auto& block = result.value().blocks[0]; + EXPECT_EQ(block.canonicalName, "NODE"); + EXPECT_EQ(block.originalLine, "*NoDe"); + EXPECT_EQ(block.location.file, result.value().sourcePath); + EXPECT_EQ(block.location.line, 3U); + + ASSERT_EQ(block.data.size(), 1U); + EXPECT_EQ( + block.data[0].fields, + (std::vector{"0007", "Label_A", "", ""})); + EXPECT_EQ(block.data[0].location.file, result.value().sourcePath); + EXPECT_EQ(block.data[0].location.line, 4U); +}