feat(domain-and-input-skeleton): step 2 — abaqus-scoped-syntax-parser

This commit is contained in:
KOKO\Mimi
2026-07-30 18:01:05 +09:00
parent ceaf92f2a9
commit abbc408aec
9 changed files with 807 additions and 1 deletions
+1
View File
@@ -27,6 +27,7 @@ include(cmake/FesaDependencies.cmake)
add_library(fesa_core STATIC add_library(fesa_core STATIC
src/fesa/core/version.cpp src/fesa/core/version.cpp
src/fesa/io/abaqus/parser.cpp
src/fesa/model/domain.cpp src/fesa/model/domain.cpp
src/fesa/model/domain_builder.cpp src/fesa/model/domain_builder.cpp
) )
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
#include <fesa/core/source_location.hpp>
namespace fesa {
struct DeckRecord final {
std::string keyword;
std::map<std::string, std::string, std::less<>> parameters;
std::vector<std::vector<std::string>> data;
SourceLocation source;
};
struct ParsedPart final {
std::string name;
std::vector<DeckRecord> records;
SourceLocation source;
};
struct ParsedInstance final {
std::string name;
std::string part_name;
std::vector<std::vector<std::string>> transform_data;
SourceLocation source;
};
struct ParsedAssembly final {
std::string name;
std::vector<ParsedInstance> instances;
std::vector<DeckRecord> records;
SourceLocation source;
};
struct ParsedDeck final {
std::vector<DeckRecord> global_records;
std::vector<ParsedPart> parts;
std::optional<ParsedAssembly> assembly;
};
} // namespace fesa
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <filesystem>
#include <optional>
#include <vector>
#include <fesa/core/diagnostic.hpp>
#include <fesa/io/abaqus/deck_record.hpp>
namespace fesa {
struct ParseDeckResult final {
std::optional<ParsedDeck> deck;
std::vector<Diagnostic> diagnostics;
};
[[nodiscard]] ParseDeckResult parse_deck(
const std::filesystem::path& path);
} // namespace fesa
+1 -1
View File
@@ -42,4 +42,4 @@
"status": "pending" "status": "pending"
} }
] ]
} }
+411
View File
@@ -0,0 +1,411 @@
#include <fesa/io/abaqus/parser.hpp>
#include <algorithm>
#include <array>
#include <cstddef>
#include <fstream>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace fesa {
namespace {
enum class Scope { global, part, assembly, instance };
struct KeywordLine final {
std::string keyword;
std::map<std::string, std::string, std::less<>> parameters;
SourceLocation source;
};
std::string_view trim(const std::string_view value) {
constexpr std::string_view whitespace{" \t\f\v\r\n"};
const std::size_t first = value.find_first_not_of(whitespace);
if (first == std::string_view::npos) {
return {};
}
const std::size_t last = value.find_last_not_of(whitespace);
return value.substr(first, last - first + 1);
}
std::string uppercase_ascii(std::string value) {
std::ranges::transform(value, value.begin(), [](const char character) {
if (character >= 'a' && character <= 'z') {
return static_cast<char>(character - 'a' + 'A');
}
return character;
});
return value;
}
std::vector<std::string> split_fields(const std::string_view value) {
std::vector<std::string> fields;
std::size_t first = 0;
while (true) {
const std::size_t comma = value.find(',', first);
const std::string_view field = comma == std::string_view::npos
? value.substr(first)
: value.substr(first, comma - first);
fields.emplace_back(trim(field));
if (comma == std::string_view::npos) {
break;
}
first = comma + 1;
}
return fields;
}
ParseDeckResult failure(
const DiagnosticStage stage,
std::string code,
std::string message,
std::optional<SourceLocation> source) {
std::vector<Diagnostic> diagnostics;
diagnostics.push_back({
stage,
Severity::error,
std::move(code),
std::move(message),
std::move(source),
});
return {std::nullopt, std::move(diagnostics)};
}
ParseDeckResult syntax_failure(
std::string code,
std::string message,
SourceLocation source) {
return failure(
DiagnosticStage::syntax,
std::move(code),
std::move(message),
std::move(source));
}
bool is_supported_record(const std::string_view keyword) {
constexpr std::array supported{
std::string_view{"BEAM GENERAL SECTION"},
std::string_view{"BOUNDARY"},
std::string_view{"CLOAD"},
std::string_view{"ELASTIC"},
std::string_view{"ELEMENT"},
std::string_view{"ELSET"},
std::string_view{"END STEP"},
std::string_view{"MATERIAL"},
std::string_view{"NODE"},
std::string_view{"NSET"},
std::string_view{"STATIC"},
std::string_view{"STEP"},
std::string_view{"TRANSVERSE SHEAR STIFFNESS"},
};
return std::ranges::find(supported, keyword) != supported.end();
}
std::optional<KeywordLine> parse_keyword_line(
const std::string_view line,
const SourceLocation& source,
ParseDeckResult& error) {
const std::vector<std::string> fields = split_fields(line.substr(1));
if (fields.empty() || fields[0].empty()) {
error = syntax_failure(
"abaqus.syntax.empty_keyword",
"Abaqus keyword name is empty.",
source);
return std::nullopt;
}
KeywordLine parsed{
uppercase_ascii(fields[0]),
{},
source,
};
for (std::size_t index = 1; index < fields.size(); ++index) {
const std::string_view field = fields[index];
if (field.empty()) {
continue;
}
const std::size_t equals = field.find('=');
const std::string_view key_text =
trim(field.substr(0, equals));
const std::string_view value_text =
equals == std::string_view::npos
? std::string_view{}
: trim(field.substr(equals + 1));
if (key_text.empty()) {
error = syntax_failure(
"abaqus.syntax.invalid_parameter",
"Abaqus keyword parameter name is empty.",
source);
return std::nullopt;
}
const std::string key = uppercase_ascii(std::string{key_text});
if (!parsed.parameters.emplace(key, value_text).second) {
error = syntax_failure(
"abaqus.syntax.duplicate_parameter",
"Abaqus keyword parameter '" + key +
"' is specified more than once.",
source);
return std::nullopt;
}
}
return parsed;
}
const std::string* parameter(
const KeywordLine& keyword,
const std::string_view name) {
const auto found = keyword.parameters.find(name);
return found == keyword.parameters.end() ? nullptr : &found->second;
}
ParseDeckResult missing_parameter(
const KeywordLine& keyword,
const std::string_view parameter_name) {
return syntax_failure(
"abaqus.syntax.missing_parameter",
"Abaqus *" + keyword.keyword + " requires parameter " +
std::string{parameter_name} + ".",
keyword.source);
}
} // namespace
ParseDeckResult parse_deck(const std::filesystem::path& path) {
std::ifstream input{path, std::ios::binary};
if (!input) {
return failure(
DiagnosticStage::io,
"abaqus.io.open_failed",
"Unable to open Abaqus input file.",
SourceLocation{path, 0U, 0U});
}
ParsedDeck deck;
Scope scope = Scope::global;
std::optional<ParsedPart> current_part;
std::optional<ParsedAssembly> current_assembly;
std::optional<ParsedInstance> current_instance;
DeckRecord* current_record = nullptr;
std::string line;
std::size_t line_number = 0;
while (std::getline(input, line)) {
++line_number;
if (line_number == 1U && line.starts_with("\xEF\xBB\xBF")) {
line.erase(0, 3);
}
const std::size_t first_nonspace =
line.find_first_not_of(" \t\f\v\r");
if (first_nonspace == std::string::npos) {
continue;
}
const std::string_view content = trim(line);
if (content.starts_with("**")) {
continue;
}
if (!content.starts_with('*')) {
const std::vector<std::string> fields = split_fields(content);
if (scope == Scope::instance) {
current_instance->transform_data.push_back(fields);
} else if (current_record != nullptr) {
current_record->data.push_back(fields);
} else {
return syntax_failure(
"abaqus.syntax.data_without_keyword",
"Abaqus data line has no preceding keyword record.",
SourceLocation{path, line_number, first_nonspace + 1U});
}
continue;
}
const SourceLocation source{
path,
line_number,
first_nonspace + 1U,
};
ParseDeckResult keyword_error;
std::optional<KeywordLine> parsed =
parse_keyword_line(content, source, keyword_error);
if (!parsed.has_value()) {
return keyword_error;
}
KeywordLine& keyword = *parsed;
current_record = nullptr;
if (keyword.keyword == "PART") {
if (scope != Scope::global) {
return syntax_failure(
"abaqus.syntax.invalid_part_scope",
"*PART is only valid in global input scope.",
source);
}
const std::string* name = parameter(keyword, "NAME");
if (name == nullptr || name->empty()) {
return missing_parameter(keyword, "NAME");
}
current_part = ParsedPart{*name, {}, source};
scope = Scope::part;
continue;
}
if (keyword.keyword == "END PART") {
if (scope != Scope::part || !current_part.has_value()) {
return syntax_failure(
"abaqus.syntax.unexpected_end_part",
"*END PART does not match an open *PART.",
source);
}
deck.parts.push_back(std::move(*current_part));
current_part.reset();
scope = Scope::global;
continue;
}
if (keyword.keyword == "ASSEMBLY") {
if (scope != Scope::global) {
return syntax_failure(
"abaqus.syntax.invalid_assembly_scope",
"*ASSEMBLY is only valid in global input scope.",
source);
}
if (deck.assembly.has_value() ||
current_assembly.has_value()) {
return syntax_failure(
"abaqus.syntax.multiple_assemblies",
"ParsedDeck can preserve only one *ASSEMBLY.",
source);
}
const std::string* name = parameter(keyword, "NAME");
if (name == nullptr || name->empty()) {
return missing_parameter(keyword, "NAME");
}
current_assembly = ParsedAssembly{*name, {}, {}, source};
scope = Scope::assembly;
continue;
}
if (keyword.keyword == "END ASSEMBLY") {
if (scope != Scope::assembly ||
!current_assembly.has_value()) {
return syntax_failure(
"abaqus.syntax.unexpected_end_assembly",
"*END ASSEMBLY does not match an open *ASSEMBLY.",
source);
}
deck.assembly = std::move(*current_assembly);
current_assembly.reset();
scope = Scope::global;
continue;
}
if (keyword.keyword == "INSTANCE") {
if (scope != Scope::assembly ||
!current_assembly.has_value()) {
return syntax_failure(
"abaqus.syntax.invalid_instance_scope",
"*INSTANCE is only valid in an open *ASSEMBLY.",
source);
}
const std::string* name = parameter(keyword, "NAME");
if (name == nullptr || name->empty()) {
return missing_parameter(keyword, "NAME");
}
const std::string* part_name = parameter(keyword, "PART");
if (part_name == nullptr || part_name->empty()) {
return missing_parameter(keyword, "PART");
}
current_instance =
ParsedInstance{*name, *part_name, {}, source};
scope = Scope::instance;
continue;
}
if (keyword.keyword == "END INSTANCE") {
if (scope != Scope::instance ||
!current_instance.has_value() ||
!current_assembly.has_value()) {
return syntax_failure(
"abaqus.syntax.unexpected_end_instance",
"*END INSTANCE does not match an open *INSTANCE.",
source);
}
current_assembly->instances.push_back(
std::move(*current_instance));
current_instance.reset();
scope = Scope::assembly;
continue;
}
if (!is_supported_record(keyword.keyword)) {
return syntax_failure(
"abaqus.unsupported_keyword",
"Unsupported Abaqus keyword *" + keyword.keyword + ".",
source);
}
DeckRecord next_record{
std::move(keyword.keyword),
std::move(keyword.parameters),
{},
source,
};
switch (scope) {
case Scope::global:
deck.global_records.push_back(std::move(next_record));
current_record = &deck.global_records.back();
break;
case Scope::part:
current_part->records.push_back(std::move(next_record));
current_record = &current_part->records.back();
break;
case Scope::assembly:
current_assembly->records.push_back(
std::move(next_record));
current_record = &current_assembly->records.back();
break;
case Scope::instance:
return syntax_failure(
"abaqus.syntax.instance_local_keyword",
"Keyword records inside *INSTANCE are unsupported.",
source);
}
}
if (input.bad()) {
return failure(
DiagnosticStage::io,
"abaqus.io.read_failed",
"Failed while reading Abaqus input file.",
SourceLocation{path, line_number, 0U});
}
if (current_instance.has_value()) {
return syntax_failure(
"abaqus.syntax.unclosed_instance",
"Abaqus *INSTANCE is not closed by *END INSTANCE.",
current_instance->source);
}
if (current_part.has_value()) {
return syntax_failure(
"abaqus.syntax.unclosed_part",
"Abaqus *PART is not closed by *END PART.",
current_part->source);
}
if (current_assembly.has_value()) {
return syntax_failure(
"abaqus.syntax.unclosed_assembly",
"Abaqus *ASSEMBLY is not closed by *END ASSEMBLY.",
current_assembly->source);
}
return {std::move(deck), {}};
}
} // namespace fesa
+30
View File
@@ -110,3 +110,33 @@ add_test(
NAME DomainValidation NAME DomainValidation
COMMAND "$<TARGET_FILE:fesa_model_value_tests>" --gtest_filter=*DomainValidation* COMMAND "$<TARGET_FILE:fesa_model_value_tests>" --gtest_filter=*DomainValidation*
) )
add_executable(fesa_abaqus_parser_tests
unit/io/abaqus/parser_test.cpp
)
target_compile_features(fesa_abaqus_parser_tests PRIVATE cxx_std_20)
target_compile_options(fesa_abaqus_parser_tests PRIVATE /W4 /permissive- /EHsc)
target_compile_definitions(
fesa_abaqus_parser_tests
PRIVATE
FESA_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
target_link_libraries(fesa_abaqus_parser_tests
PRIVATE
fesa_core
GTest::gtest_main
)
add_test(
NAME AbaqusParser
COMMAND "$<TARGET_FILE:fesa_abaqus_parser_tests>"
--gtest_filter=AbaqusParser.*
)
add_test(
NAME ScopedDeck
COMMAND "$<TARGET_FILE:fesa_abaqus_parser_tests>"
--gtest_filter=ScopedDeck.*
)
+26
View File
@@ -0,0 +1,26 @@
** Minimal flat/orphan-mesh cantilever syntax fixture.
*nOdE
1, 0.0, 0.0, 0.0
** Comments and blank lines do not terminate the current data record.
2, 1.0, 0.0, 0.0,
*eLeMeNt, TYPE=B31, elset=Beam
1, 1, 2
*NSET, NSET=Fixed
1
*ELSET, ELSET=Beam
1
*MATERIAL, NAME=Steel
*ELASTIC
210000.0, 0.3
*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel
1.0, 1.0, 1.0, 1.0, 1.0
0.0, 1.0, 0.0
*BOUNDARY
Fixed, 1, 6
*STEP, NAME=Load
*STATIC
*CLOAD
2, 2, -1.0
*END STEP
@@ -0,0 +1,37 @@
** Minimal scoped cantilever syntax fixture.
*PART, NAME=BeamPart
*NODE
1, 0.0, 0.0, 0.0
2, 1.0, 0.0, 0.0
*ELEMENT, TYPE=B31, ELSET=Beam
1, 1, 2
*NSET, NSET=Fixed
1
*NSET, NSET=Tip
2
*ELSET, ELSET=Beam
1
*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel
1.0, 1.0, 1.0, 1.0, 1.0
0.0, 1.0, 0.0
*END PART
*ASSEMBLY, NAME=RootAssembly
*INSTANCE, NAME=Beam-1, PART=BeamPart
*END INSTANCE
*NSET, NSET=Fixed, INSTANCE=Beam-1
1
*NSET, NSET=Tip, INSTANCE=Beam-1
2
*END ASSEMBLY
*MATERIAL, NAME=Steel
*ELASTIC
210000.0, 0.3
*STEP, NAME=Load
*STATIC
*BOUNDARY
Fixed, 1, 6
*CLOAD
Tip, 2, -1.0
*END STEP
+235
View File
@@ -0,0 +1,235 @@
#include <fesa/io/abaqus/parser.hpp>
#include <algorithm>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
#include <gtest/gtest.h>
namespace {
class TemporaryDeck final {
public:
TemporaryDeck(std::string_view name, std::string_view contents)
: path_{std::filesystem::path{testing::TempDir()} / name} {
std::ofstream output{path_, std::ios::binary};
output.write(
contents.data(), static_cast<std::streamsize>(contents.size()));
if (!output) {
throw std::runtime_error{"Failed to write temporary Abaqus deck."};
}
}
~TemporaryDeck() {
std::error_code error;
std::filesystem::remove(path_, error);
}
TemporaryDeck(const TemporaryDeck&) = delete;
TemporaryDeck& operator=(const TemporaryDeck&) = delete;
[[nodiscard]] const std::filesystem::path& path() const noexcept {
return path_;
}
private:
std::filesystem::path path_;
};
std::filesystem::path fixture_path(std::string_view name) {
return std::filesystem::path{FESA_TEST_SOURCE_DIR} / "fixtures" /
"abaqus" / name;
}
const fesa::DeckRecord& record(
const std::vector<fesa::DeckRecord>& records,
const std::string_view keyword) {
const auto found = std::ranges::find(
records, keyword, &fesa::DeckRecord::keyword);
if (found == records.end()) {
throw std::runtime_error{"Expected deck record was not parsed."};
}
return *found;
}
TEST(AbaqusParser, ParsesCaseInsensitiveKeywordsCommentsAndCommaFields) {
const auto path = fixture_path("minimal_cantilever.inp");
const auto result = fesa::parse_deck(path);
ASSERT_TRUE(result.deck.has_value());
EXPECT_TRUE(result.diagnostics.empty());
EXPECT_TRUE(result.deck->parts.empty());
EXPECT_FALSE(result.deck->assembly.has_value());
const auto& nodes = record(result.deck->global_records, "NODE");
ASSERT_EQ(nodes.data.size(), 2U);
EXPECT_EQ(nodes.data[0], (std::vector<std::string>{
"1", "0.0", "0.0", "0.0"}));
EXPECT_EQ(nodes.data[1], (std::vector<std::string>{
"2", "1.0", "0.0", "0.0", ""}));
EXPECT_EQ(nodes.source.file, path);
EXPECT_EQ(nodes.source.line, 3U);
EXPECT_EQ(nodes.source.column, 1U);
const auto& element = record(result.deck->global_records, "ELEMENT");
EXPECT_EQ(element.parameters.at("TYPE"), "B31");
EXPECT_EQ(element.parameters.at("ELSET"), "Beam");
EXPECT_EQ(element.source.line, 8U);
}
TEST(AbaqusParser, PreservesUtf8ParameterValues) {
const std::string utf8_name{
"\xEB\xB9\x94\xEB\xB6\x80\xED\x92\x88"};
const TemporaryDeck input{
"fesa-parser-utf8.inp",
"*MATERIAL, NAME=" + utf8_name + "\n"
"*ELASTIC\n"
"210000.0, 0.3\n"};
const auto result = fesa::parse_deck(input.path());
ASSERT_TRUE(result.deck.has_value());
ASSERT_TRUE(result.diagnostics.empty());
const auto& material = record(result.deck->global_records, "MATERIAL");
EXPECT_EQ(material.parameters.at("NAME"), utf8_name);
}
TEST(AbaqusParser, RejectsUnsupportedKeywordsInsteadOfIgnoringThem) {
const TemporaryDeck input{
"fesa-parser-unsupported.inp",
"** The error source must identify the keyword line.\n"
"*INCLUDE, INPUT=other.inp\n"};
const auto result = fesa::parse_deck(input.path());
EXPECT_FALSE(result.deck.has_value());
ASSERT_EQ(result.diagnostics.size(), 1U);
EXPECT_EQ(
result.diagnostics[0].stage, fesa::DiagnosticStage::syntax);
EXPECT_EQ(
result.diagnostics[0].code, "abaqus.unsupported_keyword");
ASSERT_TRUE(result.diagnostics[0].source.has_value());
EXPECT_EQ(result.diagnostics[0].source->file, input.path());
EXPECT_EQ(result.diagnostics[0].source->line, 2U);
}
TEST(ScopedDeck, PreservesPartAssemblyAndInstanceScopes) {
const auto path =
fixture_path("minimal_part_instance_cantilever.inp");
const auto result = fesa::parse_deck(path);
ASSERT_TRUE(result.deck.has_value());
EXPECT_TRUE(result.diagnostics.empty());
ASSERT_EQ(result.deck->parts.size(), 1U);
EXPECT_EQ(result.deck->parts[0].name, "BeamPart");
EXPECT_EQ(result.deck->parts[0].source.file, path);
EXPECT_EQ(result.deck->parts[0].source.line, 2U);
EXPECT_EQ(record(result.deck->parts[0].records, "NODE").source.line, 3U);
ASSERT_TRUE(result.deck->assembly.has_value());
EXPECT_EQ(result.deck->assembly->name, "RootAssembly");
EXPECT_EQ(result.deck->assembly->source.line, 19U);
ASSERT_EQ(result.deck->assembly->instances.size(), 1U);
EXPECT_EQ(result.deck->assembly->instances[0].name, "Beam-1");
EXPECT_EQ(result.deck->assembly->instances[0].part_name, "BeamPart");
EXPECT_EQ(result.deck->assembly->instances[0].source.line, 20U);
EXPECT_TRUE(
result.deck->assembly->instances[0].transform_data.empty());
EXPECT_EQ(
record(result.deck->assembly->records, "NSET")
.parameters.at("INSTANCE"),
"Beam-1");
EXPECT_NE(
std::ranges::find(
result.deck->global_records,
"MATERIAL",
&fesa::DeckRecord::keyword),
result.deck->global_records.end());
}
TEST(ScopedDeck, PreservesInstanceTransformDataWithoutApplyingIt) {
const TemporaryDeck input{
"fesa-parser-transform.inp",
"*PART, NAME=BeamPart\n"
"*END PART\n"
"*ASSEMBLY, NAME=RootAssembly\n"
"*INSTANCE, NAME=Beam-1, PART=BeamPart\n"
"1.0, 2.0, 3.0\n"
"0.0, 0.0, 1.0, 90.0\n"
"*END INSTANCE\n"
"*END ASSEMBLY\n"};
const auto result = fesa::parse_deck(input.path());
ASSERT_TRUE(result.deck.has_value());
ASSERT_TRUE(result.deck->assembly.has_value());
ASSERT_EQ(result.deck->assembly->instances.size(), 1U);
EXPECT_EQ(
result.deck->assembly->instances[0].transform_data,
(std::vector<std::vector<std::string>>{
{"1.0", "2.0", "3.0"},
{"0.0", "0.0", "1.0", "90.0"}}));
}
TEST(ScopedDeck, ReportsUnexpectedAndUnclosedScopeTerminators) {
struct Case final {
std::string_view name;
std::string_view contents;
std::string_view code;
std::size_t line;
};
const Case cases[]{
{
"fesa-parser-unexpected-end-part.inp",
"*END PART\n",
"abaqus.syntax.unexpected_end_part",
1U,
},
{
"fesa-parser-unclosed-part.inp",
"*PART, NAME=BeamPart\n"
"*NODE\n"
"1, 0.0, 0.0, 0.0\n",
"abaqus.syntax.unclosed_part",
1U,
},
{
"fesa-parser-unexpected-end-instance.inp",
"*ASSEMBLY, NAME=RootAssembly\n"
"*END INSTANCE\n",
"abaqus.syntax.unexpected_end_instance",
2U,
},
{
"fesa-parser-unclosed-assembly.inp",
"*ASSEMBLY, NAME=RootAssembly\n",
"abaqus.syntax.unclosed_assembly",
1U,
},
};
for (const auto& test_case : cases) {
const TemporaryDeck input{test_case.name, test_case.contents};
const auto result = fesa::parse_deck(input.path());
SCOPED_TRACE(test_case.name);
EXPECT_FALSE(result.deck.has_value());
ASSERT_EQ(result.diagnostics.size(), 1U);
EXPECT_EQ(result.diagnostics[0].code, test_case.code);
ASSERT_TRUE(result.diagnostics[0].source.has_value());
EXPECT_EQ(result.diagnostics[0].source->line, test_case.line);
}
}
} // namespace