feat(linear-static-3d-euler-beam): step 11 - inp-syntax-parser

This commit is contained in:
KOKO\Mimi
2026-08-09 15:19:29 +09:00
parent 3b56fc906b
commit b73f6cd823
8 changed files with 550 additions and 0 deletions
@@ -189,3 +189,52 @@
- handoff: backend-free `EntityIndex`, semantic model records, - handoff: backend-free `EntityIndex`, semantic model records,
`ModelDefinition`, and immutable `Domain::create`/const accessors are `ModelDefinition`, and immutable `Domain::create`/const accessors are
available to Step 11 syntax parsing and Step 12 semantic mapping. 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.
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "fesa/core/status.hpp"
#include "fesa/io/abaqus/input_syntax.hpp"
#include <filesystem>
namespace fesa {
// Reads only physical keyword/data/comment syntax; semantic policy is applied
// later by AbaqusDomainMapper.
class AbaqusInputReader {
public:
Result<ParsedInput> read(const std::filesystem::path& inputPath) const;
};
} // namespace fesa
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "fesa/core/source_identity.hpp"
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace fesa {
// Names are canonicalized for syntax lookup while values remain source text.
struct KeywordParameter {
std::string name;
std::optional<std::string> value;
};
struct DataLine {
std::vector<std::string> fields;
SourceLocation location;
};
struct KeywordBlock {
std::string canonicalName;
std::string originalLine;
std::vector<KeywordParameter> parameters;
std::vector<DataLine> data;
SourceLocation location;
};
struct ParsedInput {
std::filesystem::path sourcePath;
std::string sourceContentIdentity;
std::vector<KeywordBlock> blocks;
};
} // namespace fesa
+1
View File
@@ -4,6 +4,7 @@ add_library(
build_info.cpp build_info.cpp
core/diagnostic.cpp core/diagnostic.cpp
core/status.cpp core/status.cpp
io/abaqus/input_reader.cpp
math/matrix.cpp math/matrix.cpp
math/vector.cpp math/vector.cpp
model/domain.cpp model/domain.cpp
+216
View File
@@ -0,0 +1,216 @@
#include "fesa/io/abaqus/input_reader.hpp"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <iterator>
#include <sstream>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
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<char>(character - 'a' + 'A');
}
return character;
});
return value;
}
std::vector<std::string> splitFields(std::string_view line) {
std::vector<std::string> 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<std::uint64_t>(byte);
hash *= prime;
}
std::ostringstream formatted;
formatted << "fnv1a64:" << std::hex << std::setfill('0')
<< std::setw(16) << hash;
return formatted.str();
}
Result<ParsedInput> 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<ParsedInput>::failure(Status::failure(
FailureCategory::input, {std::move(diagnostic)}));
}
} // namespace
Result<ParsedInput> 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<char>{stream},
std::istreambuf_iterator<char>{}};
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<ParsedInput>::success(std::move(parsed));
}
} // namespace fesa
+2
View File
@@ -8,6 +8,8 @@ add_executable(
unit/core/status_test.cpp unit/core/status_test.cpp
unit/math/matrix_test.cpp unit/math/matrix_test.cpp
unit/math/vector_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/domain_test.cpp
unit/model/model_types_test.cpp unit/model/model_types_test.cpp
) )
+133
View File
@@ -0,0 +1,133 @@
#include "fesa/io/abaqus/input_reader.hpp"
#include <gtest/gtest.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
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<std::streamsize>(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<char>{stream},
std::istreambuf_iterator<char>{}};
}
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);
}
@@ -0,0 +1,95 @@
#include "fesa/io/abaqus/input_reader.hpp"
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>
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<std::streamsize>(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<std::string>{"0007", "Label_A", "", ""}));
EXPECT_EQ(block.data[0].location.file, result.value().sourcePath);
EXPECT_EQ(block.data[0].location.line, 4U);
}