diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index f462c70074..b2cc01015f 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -130,6 +130,9 @@ set( KICOMMON_SRCS project/project_local_settings.cpp project/time_domain_parameters.cpp + text_eval/text_eval_wrapper.cpp + text_eval/text_eval_parser.cpp + # This is basically a settings object, but for the toolbar tool/ui/toolbar_configuration.cpp @@ -1014,6 +1017,13 @@ generate_lemon_grammar( libeval_compiler/grammar.lemon ) +generate_lemon_grammar( + kicommon + text_eval + text_eval/text_eval_wrapper.cpp + text_eval/text_eval.lemon + ) + # auto-generate stroke_params_lexer.h and stroke_params_keywords.cpp # Called twice one for common and one for gal, to ensure the files are created # on all devel tools ( Linux and msys2 ) diff --git a/common/eda_text.cpp b/common/eda_text.cpp index 1dd16fdabe..4c6b75b0f8 100644 --- a/common/eda_text.cpp +++ b/common/eda_text.cpp @@ -35,6 +35,7 @@ #include #include #include // for UnescapeString +#include #include // for KiROUND #include #include @@ -638,6 +639,14 @@ void EDA_TEXT::cacheShownText() } +wxString EDA_TEXT::EvaluateText( const wxString& aText ) const +{ + static EXPRESSION_EVALUATOR evaluator; + + return evaluator.Evaluate( aText ); +} + + KIFONT::FONT* EDA_TEXT::GetDrawFont( const RENDER_SETTINGS* aSettings ) const { KIFONT::FONT* font = GetFont(); diff --git a/common/gr_text.cpp b/common/gr_text.cpp index 2a2a71149a..06f4467096 100644 --- a/common/gr_text.cpp +++ b/common/gr_text.cpp @@ -29,6 +29,7 @@ #include #include // for KiROUND #include +#include #include @@ -100,8 +101,15 @@ int GRTextWidth( const wxString& aText, KIFONT::FONT* aFont, const VECTOR2I& aSi { if( !aFont ) aFont = KIFONT::FONT::GetFont(); + wxString evaluated( aText ); - return KiROUND( aFont->StringBoundaryLimits( aText, aSize, aThickness, aBold, aItalic, + if( evaluated.Contains( wxS( "@{" ) ) ) + { + EXPRESSION_EVALUATOR evaluator; + evaluated = evaluator.Evaluate( evaluated ); + } + + return KiROUND( aFont->StringBoundaryLimits( evaluated, aSize, aThickness, aBold, aItalic, aFontMetrics ).x ); } @@ -114,6 +122,13 @@ void GRPrintText( wxDC* aDC, const VECTOR2I& aPos, const COLOR4D& aColor, const { KIGFX::GAL_DISPLAY_OPTIONS empty_opts; bool fill_mode = true; + wxString evaluatedText( aText ); + + if( evaluatedText.Contains( wxS( "@{" ) ) ) + { + EXPRESSION_EVALUATOR evaluator; + evaluatedText = evaluator.Evaluate( evaluatedText ); + } if( !aFont ) aFont = KIFONT::FONT::GetFont(); @@ -156,7 +171,7 @@ void GRPrintText( wxDC* aDC, const VECTOR2I& aPos, const COLOR4D& aColor, const attributes.m_Valign = aV_justify; attributes.m_Size = aSize; - aFont->Draw( &callback_gal, aText, aPos, attributes, aFontMetrics ); + aFont->Draw( &callback_gal, evaluatedText, aPos, attributes, aFontMetrics ); } diff --git a/common/plotters/plotter.cpp b/common/plotters/plotter.cpp index 60544002d3..33755519f7 100644 --- a/common/plotters/plotter.cpp +++ b/common/plotters/plotter.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -637,6 +638,13 @@ void PLOTTER::Text( const VECTOR2I& aPos, void* aData ) { KIGFX::GAL_DISPLAY_OPTIONS empty_opts; + wxString text( aText ); + + if( text.Contains( wxS( "@{" ) ) ) + { + EXPRESSION_EVALUATOR evaluator; + text = evaluator.Evaluate( text ); + } SetColor( aColor ); @@ -680,7 +688,7 @@ void PLOTTER::Text( const VECTOR2I& aPos, if( !aFont ) aFont = KIFONT::FONT::GetFont( m_renderSettings->GetDefaultFont() ); - aFont->Draw( &callback_gal, aText, aPos, attributes, aFontMetrics ); + aFont->Draw( &callback_gal, text, aPos, attributes, aFontMetrics ); } @@ -693,6 +701,13 @@ void PLOTTER::PlotText( const VECTOR2I& aPos, void* aData ) { KIGFX::GAL_DISPLAY_OPTIONS empty_opts; + wxString text( aText ); + + if( text.Contains( wxS( "@{" ) ) ) + { + EXPRESSION_EVALUATOR evaluator; + text = evaluator.Evaluate( text ); + } TEXT_ATTRIBUTES attributes = aAttributes; int penWidth = attributes.m_StrokeWidth; @@ -725,5 +740,5 @@ void PLOTTER::PlotText( const VECTOR2I& aPos, if( !aFont ) aFont = KIFONT::FONT::GetFont( m_renderSettings->GetDefaultFont() ); - aFont->Draw( &callback_gal, aText, aPos, attributes, aFontMetrics ); + aFont->Draw( &callback_gal, text, aPos, attributes, aFontMetrics ); } diff --git a/common/text_eval/text_eval.lemon b/common/text_eval/text_eval.lemon new file mode 100644 index 0000000000..8601a7ab03 --- /dev/null +++ b/common/text_eval/text_eval.lemon @@ -0,0 +1,200 @@ +%include { +#include +using namespace calc_parser; +} + +%token_type {calc_parser::TOKEN_TYPE} +%token_prefix KI_EVAL_ +%default_type {calc_parser::NODE*} +%extra_argument {calc_parser::DOC** pDocument} + +%type document {calc_parser::DOC*} +%type content_list {calc_parser::DOC*} +%type content_item {calc_parser::NODE*} +%type calculation {calc_parser::NODE*} +%type expression {calc_parser::NODE*} +%type term {calc_parser::NODE*} +%type factor {calc_parser::NODE*} +%type variable {calc_parser::NODE*} +%type function_call {calc_parser::NODE*} +%type arg_list {std::vector>*} + +%destructor document { delete $$; } +%destructor content_list { delete $$; } +%destructor content_item { delete $$; } +%destructor calculation { delete $$; } +%destructor expression { delete $$; } +%destructor term { delete $$; } +%destructor factor { delete $$; } +%destructor variable { delete $$; } +%destructor function_call { delete $$; } +%destructor arg_list { delete $$; } + +%left LT GT LE GE EQ NE. +%left PLUS MINUS. +%left MULTIPLY DIVIDE MODULO. +%right UMINUS. +%right POWER. +%token COMMA. + +%start_symbol document + +// Main document structure +document(D) ::= content_list(L). { + D = L; + *pDocument = D; // Store the result in the extra argument +} + +content_list(L) ::= . { + L = new DOC(); +} + +content_list(L) ::= content_list(L) content_item(I). { + L->AddNodeRaw(I); +} + +content_item(I) ::= TEXT(T). { + I = NODE::CreateTextRaw(GetTokenString(T)); +} + +content_item(I) ::= calculation(C). { + I = C; +} + +content_item(I) ::= variable(V). { + I = NODE::CreateCalcRaw(V); +} + +calculation(C) ::= AT_OPEN expression(E) CLOSE_BRACE. { + C = NODE::CreateCalcRaw(E); +} + +// Mathematical expressions with proper precedence +expression(E) ::= expression(L) LT expression(R). { + E = NODE::CreateBinOpRaw(L, '<', R); +} + +expression(E) ::= expression(L) GT expression(R). { + E = NODE::CreateBinOpRaw(L, '>', R); +} + +expression(E) ::= expression(L) LE expression(R). { + E = NODE::CreateBinOpRaw(L, 1, R); // Using 1 for <= +} + +expression(E) ::= expression(L) GE expression(R). { + E = NODE::CreateBinOpRaw(L, 2, R); // Using 2 for >= +} + +expression(E) ::= expression(L) EQ expression(R). { + E = NODE::CreateBinOpRaw(L, 3, R); // Using 3 for == +} + +expression(E) ::= expression(L) NE expression(R). { + E = NODE::CreateBinOpRaw(L, 4, R); // Using 4 for != +} + +expression(E) ::= expression(L) PLUS expression(R). { + E = NODE::CreateBinOpRaw(L, '+', R); +} + +expression(E) ::= expression(L) MINUS expression(R). { + E = NODE::CreateBinOpRaw(L, '-', R); +} + +expression(E) ::= term(T). { + E = T; +} + +term(T) ::= term(L) MULTIPLY term(R). { + T = NODE::CreateBinOpRaw(L, '*', R); +} + +term(T) ::= term(L) DIVIDE term(R). { + T = NODE::CreateBinOpRaw(L, '/', R); +} + +term(T) ::= term(L) MODULO term(R). { + T = NODE::CreateBinOpRaw(L, '%', R); +} + +term(T) ::= factor(F). { + T = F; +} + +factor(F) ::= factor(L) POWER factor(R). { + F = NODE::CreateBinOpRaw(L, '^', R); +} + +factor(F) ::= MINUS factor(R). [UMINUS] { + F = NODE::CreateBinOpRaw(NODE::CreateNumberRaw(0.0), '-', R); +} + +factor(F) ::= PLUS factor(R). [UMINUS] { + F = R; +} + +factor(F) ::= LPAREN expression(E) RPAREN. { + F = E; +} + +factor(F) ::= NUMBER(N). { + try + { + F = NODE::CreateNumberRaw( N.isString ? std::stod( GetTokenString( N ) ) : GetTokenDouble( N ) ); + } + catch (const std::exception&) + { + if (g_errorCollector) + { + g_errorCollector->AddError( std::format( "Invalid number format: {}", GetTokenString( N ) ) ); + } + F = NODE::CreateNumberRaw( 0.0 ); + } +} + +factor(F) ::= STRING(S). { + F = NODE::CreateStringRaw( GetTokenString( S ) ); +} + +factor(F) ::= variable(V). { + F = V; +} + +factor(F) ::= function_call(FC). { + F = FC; +} + +function_call(FC) ::= IDENTIFIER(I) LPAREN RPAREN. { + auto empty_args = new std::vector>(); + FC = NODE::CreateFunctionRaw(GetTokenString(I), empty_args); +} + +function_call(FC) ::= IDENTIFIER(I) LPAREN arg_list(AL) RPAREN. { + FC = NODE::CreateFunctionRaw(GetTokenString(I), AL); +} + +arg_list(AL) ::= expression(E). { + AL = new std::vector>(); + AL->emplace_back(std::unique_ptr(E)); +} + +arg_list(AL) ::= arg_list(AL) COMMA expression(E). { + AL->emplace_back(std::unique_ptr(E)); +} + +variable(V) ::= DOLLAR_OPEN IDENTIFIER(I) CLOSE_BRACE. { + V = NODE::CreateVarRaw(GetTokenString(I)); +} + +%syntax_error { + if (g_errorCollector) { + g_errorCollector->AddSyntaxError(); + } +} + +%parse_failure { + if (g_errorCollector) { + g_errorCollector->AddParseFailure(); + } +} diff --git a/common/text_eval/text_eval_parser.cpp b/common/text_eval/text_eval_parser.cpp new file mode 100644 index 0000000000..aaf5c6fcac --- /dev/null +++ b/common/text_eval/text_eval_parser.cpp @@ -0,0 +1,636 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include +#include +#include + +namespace calc_parser +{ +thread_local ERROR_COLLECTOR* g_errorCollector = nullptr; + +class DATE_UTILS +{ +private: + static constexpr int epochYear = 1970; + static constexpr std::array daysInMonth = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + static constexpr std::array monthNames = { + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + }; + static constexpr std::array monthAbbrev = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }; + static constexpr std::array weekdayNames = { + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" + }; + + static auto isLeapYear( int aYear ) -> bool + { + return ( aYear % 4 == 0 && aYear % 100 != 0 ) || ( aYear % 400 == 0 ); + } + + static auto daysInYear( int aYear ) -> int + { + return isLeapYear( aYear ) ? 366 : 365; + } + + static auto daysInMonthForYear( int aMonth, int aYear ) -> int + { + if( aMonth == 2 && isLeapYear( aYear ) ) + return 29; + + return daysInMonth[aMonth - 1]; + } + +public: + static auto DaysToYmd( int aDaysSinceEpoch ) -> std::tuple + { + int year = epochYear; + int remainingDays = aDaysSinceEpoch; + + if( remainingDays >= 0 ) + { + while( remainingDays >= daysInYear( year ) ) + { + remainingDays -= daysInYear( year ); + year++; + } + } + else + { + while( remainingDays < 0 ) + { + year--; + remainingDays += daysInYear( year ); + } + } + + int month = 1; + while( month <= 12 && remainingDays >= daysInMonthForYear( month, year ) ) + { + remainingDays -= daysInMonthForYear( month, year ); + month++; + } + + int day = remainingDays + 1; + return {year, month, day}; + } + + static auto YmdToDays( int aYear, int aMonth, int aDay ) -> int + { + int totalDays = 0; + + if( aYear >= epochYear ) + { + for( int y = epochYear; y < aYear; ++y ) + totalDays += daysInYear( y ); + } + else + { + for( int y = aYear; y < epochYear; ++y ) + totalDays -= daysInYear( y ); + } + + for( int m = 1; m < aMonth; ++m ) + totalDays += daysInMonthForYear( m, aYear ); + + totalDays += aDay - 1; + return totalDays; + } + + static auto ParseDate( const std::string& aDateStr ) -> std::optional + { + std::istringstream iss( aDateStr ); + std::string token; + std::vector parts; + + char separator = 0; + bool isCjkFormat = false; + + // Check for CJK date formats first (Chinese, Korean, or mixed) + bool hasChineseYear = aDateStr.find( "年" ) != std::string::npos; + bool hasChineseMonth = aDateStr.find( "月" ) != std::string::npos; + bool hasChineseDay = aDateStr.find( "日" ) != std::string::npos; + bool hasKoreanYear = aDateStr.find( "년" ) != std::string::npos; + bool hasKoreanMonth = aDateStr.find( "월" ) != std::string::npos; + bool hasKoreanDay = aDateStr.find( "일" ) != std::string::npos; + + // Check if we have any CJK date format (pure or mixed) + if( (hasChineseYear || hasKoreanYear) && + (hasChineseMonth || hasKoreanMonth) && + (hasChineseDay || hasKoreanDay) ) + { + // CJK format: Support pure Chinese, pure Korean, or mixed formats + isCjkFormat = true; + + size_t yearPos, monthPos, dayPos; + + // Find year position and marker + if( hasChineseYear ) + yearPos = aDateStr.find( "年" ); + else + yearPos = aDateStr.find( "년" ); + + // Find month position and marker + if( hasChineseMonth ) + monthPos = aDateStr.find( "月" ); + else + monthPos = aDateStr.find( "월" ); + + // Find day position and marker + if( hasChineseDay ) + dayPos = aDateStr.find( "日" ); + else + dayPos = aDateStr.find( "일" ); + + try + { + int year = std::stoi( aDateStr.substr( 0, yearPos ) ); + int month = std::stoi( aDateStr.substr( yearPos + 3, monthPos - yearPos - 3 ) ); // 3 bytes for CJK year marker + int day = std::stoi( aDateStr.substr( monthPos + 3, dayPos - monthPos - 3 ) ); // 3 bytes for CJK month marker + + parts = { year, month, day }; + } + catch( ... ) + { + return std::nullopt; + } + } + else if( aDateStr.find( '-' ) != std::string::npos ) + separator = '-'; + else if( aDateStr.find( '/' ) != std::string::npos ) + separator = '/'; + else if( aDateStr.find( '.' ) != std::string::npos ) + separator = '.'; + + if( separator ) + { + while( std::getline( iss, token, separator ) ) + { + try + { + parts.push_back( std::stoi( token ) ); + } + catch( ... ) + { + return std::nullopt; + } + } + } + else if( !isCjkFormat && aDateStr.length() == 8 ) + { + try + { + int dateNum = std::stoi( aDateStr ); + int year = dateNum / 10000; + int month = ( dateNum / 100 ) % 100; + int day = dateNum % 100; + return YmdToDays( year, month, day ); + } + catch( ... ) + { + return std::nullopt; + } + } + else if( !isCjkFormat ) + { + return std::nullopt; + } + + if( parts.empty() || parts.size() > 3 ) + return std::nullopt; + + int year, month, day; + + if( parts.size() == 1 ) + { + year = parts[0]; + month = 1; + day = 1; + } + else if( parts.size() == 2 ) + { + year = parts[0]; + month = parts[1]; + day = 1; + } + else + { + if( isCjkFormat ) + { + // CJK formats are always in YYYY年MM月DD日 or YYYY년 MM월 DD일 order + year = parts[0]; + month = parts[1]; + day = parts[2]; + } + else if( separator == '/' && parts[0] <= 12 && parts[1] <= 31 ) + { + month = parts[0]; + day = parts[1]; + year = parts[2]; + } + else if( separator == '/' && parts[1] <= 12 ) + { + day = parts[0]; + month = parts[1]; + year = parts[2]; + } + else + { + year = parts[0]; + month = parts[1]; + day = parts[2]; + } + } + + if( month < 1 || month > 12 ) + return std::nullopt; + if( day < 1 || day > daysInMonthForYear( month, year ) ) + return std::nullopt; + + return YmdToDays( year, month, day ); + } + + static auto FormatDate( int aDaysSinceEpoch, const std::string& aFormat ) -> std::string + { + auto [year, month, day] = DaysToYmd( aDaysSinceEpoch ); + + if( aFormat == "ISO" || aFormat == "iso" ) + return std::format( "{:04d}-{:02d}-{:02d}", year, month, day ); + else if( aFormat == "US" || aFormat == "us" ) + return std::format( "{:02d}/{:02d}/{:04d}", month, day, year ); + else if( aFormat == "EU" || aFormat == "european" ) + return std::format( "{:02d}/{:02d}/{:04d}", day, month, year ); + else if( aFormat == "long" ) + return std::format( "{} {}, {}", monthNames[month-1], day, year ); + else if( aFormat == "short" ) + return std::format( "{} {}, {}", monthAbbrev[month-1], day, year ); + else if( aFormat == "Chinese" || aFormat == "chinese" || aFormat == "CN" || aFormat == "cn" || aFormat == "中文" ) + return std::format( "{}年{:02d}月{:02d}日", year, month, day ); + else if( aFormat == "Japanese" || aFormat == "japanese" || aFormat == "JP" || aFormat == "jp" || aFormat == "日本語" ) + return std::format( "{}年{:02d}月{:02d}日", year, month, day ); + else if( aFormat == "Korean" || aFormat == "korean" || aFormat == "KR" || aFormat == "kr" || aFormat == "한국어" ) + return std::format( "{}년 {:02d}월 {:02d}일", year, month, day ); + else + return std::format( "{:04d}-{:02d}-{:02d}", year, month, day ); + } + + static auto GetWeekdayName( int aDaysSinceEpoch ) -> std::string + { + int weekday = ( ( aDaysSinceEpoch + 3 ) % 7 ); // +3 because epoch was Thursday (Monday = 0) + + if( weekday < 0 ) + weekday += 7; + + return std::string{ weekdayNames[weekday] }; + } + + static auto GetCurrentDays() -> int + { + auto now = std::chrono::system_clock::now(); + auto timeT = std::chrono::system_clock::to_time_t( now ); + return static_cast( timeT / ( 24 * 3600 ) ); + } + + static auto GetCurrentTimestamp() -> double + { + auto now = std::chrono::system_clock::now(); + auto timeT = std::chrono::system_clock::to_time_t( now ); + return static_cast( timeT ); + } +}; + +EVAL_VISITOR::EVAL_VISITOR( VariableCallback aVariableCallback, ERROR_COLLECTOR& aErrorCollector ) : + m_variableCallback( std::move( aVariableCallback ) ), + m_errors( aErrorCollector ), + m_gen( m_rd() ) +{} + +auto EVAL_VISITOR::operator()( const NODE& aNode ) const -> Result + { + switch( aNode.type ) + { + case NodeType::Number: + return MakeValue( std::get( aNode.data ) ); + + case NodeType::String: + return MakeValue( std::get( aNode.data ) ); + + case NodeType::Var: + { + const auto& varName = std::get( aNode.data ); + + // Use callback to resolve variable + if( m_variableCallback ) + return m_variableCallback( varName ); + + return MakeError( std::format( "No variable resolver configured for: {}", varName ) ); + } + + case NodeType::BinOp: + { + const auto& binop = std::get( aNode.data ); + auto leftResult = binop.left->Accept( *this ); + if( !leftResult ) + return leftResult; + + auto rightResult = binop.right ? + binop.right->Accept( *this ) : MakeValue( 0.0 ); + if( !rightResult ) + return rightResult; + + // Special handling for string concatenation with + + if( binop.op == '+' ) + { + const auto& leftVal = leftResult.GetValue(); + const auto& rightVal = rightResult.GetValue(); + + // If either operand is a string, concatenate + if( std::holds_alternative( leftVal ) || + std::holds_alternative( rightVal ) ) + { + return MakeValue( VALUE_UTILS::ConcatStrings( leftVal, rightVal ) ); + } + } + + // Otherwise, perform arithmetic + return VALUE_UTILS::ArithmeticOp( leftResult.GetValue(), rightResult.GetValue(), binop.op ); + } + + case NodeType::Function: + { + const auto& func = std::get( aNode.data ); + return evaluateFunction( func ); + } + + default: + return MakeError( "Cannot evaluate this node type" ); + } +} + +auto EVAL_VISITOR::evaluateFunction( const FUNC_DATA& aFunc ) const -> Result +{ + const auto& name = aFunc.name; + const auto& args = aFunc.args; + + // Zero-argument functions + if( args.empty() ) + { + if( name == "today" ) + return MakeValue( static_cast( DATE_UTILS::GetCurrentDays() ) ); + else if( name == "now" ) + return MakeValue( DATE_UTILS::GetCurrentTimestamp() ); + else if( name == "random" ) + { + std::uniform_real_distribution dis( 0.0, 1.0 ); + return MakeValue( dis( m_gen ) ); + } + } + + // Evaluate arguments to mixed types + std::vector argValues; + argValues.reserve( args.size() ); + + for( const auto& arg : args ) + { + auto result = arg->Accept( *this ); + if( !result ) + return result; + + argValues.push_back( result.GetValue() ); + } + + const auto argc = argValues.size(); + + // String formatting functions (return strings!) + if( name == "format" && argc >= 1 ) + { + auto numResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !numResult ) + return MakeError( numResult.GetError() ); + + const auto value = numResult.GetValue(); + int decimals = 2; + if( argc > 1 ) + { + auto decResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( decResult ) + decimals = static_cast( decResult.GetValue() ); + } + + return MakeValue( std::format( "{:.{}f}", value, decimals ) ); + } + else if( name == "currency" && argc >= 1 ) + { + auto numResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !numResult ) + return MakeError( numResult.GetError() ); + + const auto amount = numResult.GetValue(); + const auto symbol = argc > 1 ? VALUE_UTILS::ToString( argValues[1] ) : "$"; + + return MakeValue( std::format( "{}{:.2f}", symbol, amount ) ); + } + else if( name == "fixed" && argc >= 1 ) + { + auto numResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !numResult ) + return MakeError( numResult.GetError() ); + + const auto value = numResult.GetValue(); + int decimals = 2; + if( argc > 1 ) + { + auto decResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( decResult ) + decimals = static_cast( decResult.GetValue() ); + } + + return MakeValue( std::format( "{:.{}f}", value, decimals ) ); + } + + // Date formatting functions (return strings!) + else if( name == "dateformat" && argc >= 1 ) + { + auto dateResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !dateResult ) + return MakeError( dateResult.GetError() ); + + const auto days = static_cast( dateResult.GetValue() ); + const auto format = argc > 1 ? VALUE_UTILS::ToString( argValues[1] ) : "ISO"; + + return MakeValue( DATE_UTILS::FormatDate( days, format ) ); + } + else if( name == "datestring" && argc == 1 ) + { + auto dateStr = VALUE_UTILS::ToString( argValues[0] ); + auto daysResult = DATE_UTILS::ParseDate( dateStr ); + + if( !daysResult ) + return MakeError( "Invalid date format: " + dateStr ); + + return MakeValue( static_cast( daysResult.value() ) ); + } + else if( name == "weekdayname" && argc == 1 ) + { + auto dateResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !dateResult ) + return MakeError( dateResult.GetError() ); + + const auto days = static_cast( dateResult.GetValue() ); + return MakeValue( DATE_UTILS::GetWeekdayName( days ) ); + } + + // String functions (return strings!) + else if( name == "upper" && argc == 1 ) + { + auto str = VALUE_UTILS::ToString( argValues[0] ); + std::transform( str.begin(), str.end(), str.begin(), ::toupper ); + return MakeValue( str ); + } + else if( name == "lower" && argc == 1 ) + { + auto str = VALUE_UTILS::ToString( argValues[0] ); + std::transform( str.begin(), str.end(), str.begin(), ::tolower ); + return MakeValue( str ); + } + else if( name == "concat" && argc >= 2 ) + { + std::string result; + for( const auto& val : argValues ) + result += VALUE_UTILS::ToString( val ); + + return MakeValue( result ); + } + + // Conditional functions (handle mixed types) + if( name == "if" && argc == 3 ) + { + // Convert only the condition to a number + auto conditionResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( !conditionResult ) + return MakeError( conditionResult.GetError() ); + + const auto condition = conditionResult.GetValue() != 0.0; + return MakeValue( condition ? argValues[1] : argValues[2] ); + } + + // Mathematical functions (return numbers) - convert args to doubles first + std::vector numArgs; + for( const auto& val : argValues ) + { + auto numResult = VALUE_UTILS::ToDouble( val ); + if( !numResult ) + return MakeError( numResult.GetError() ); + + numArgs.push_back( numResult.GetValue() ); + } + + // Mathematical function implementations + if( name == "abs" && argc == 1 ) + return MakeValue( std::abs( numArgs[0] ) ); + else if( name == "sum" && argc >= 1 ) + return MakeValue( std::accumulate( numArgs.begin(), numArgs.end(), 0.0 ) ); + else if( name == "round" && argc >= 1 ) + { + const auto value = numArgs[0]; + const auto precision = argc > 1 ? static_cast( numArgs[1] ) : 0; + const auto multiplier = std::pow( 10.0, precision ); + return MakeValue( std::round( value * multiplier ) / multiplier ); + } + else if( name == "sqrt" && argc == 1 ) + { + if( numArgs[0] < 0 ) + return MakeError( "Square root of negative number" ); + + return MakeValue( std::sqrt( numArgs[0] ) ); + } + else if( name == "pow" && argc == 2 ) + return MakeValue( std::pow( numArgs[0], numArgs[1] ) ); + else if( name == "floor" && argc == 1 ) + return MakeValue( std::floor( numArgs[0] ) ); + else if( name == "ceil" && argc == 1 ) + return MakeValue( std::ceil( numArgs[0] ) ); + else if( name == "min" && argc >= 1 ) + return MakeValue( *std::min_element( numArgs.begin(), numArgs.end() ) ); + else if( name == "max" && argc >= 1 ) + return MakeValue( *std::max_element( numArgs.begin(), numArgs.end() ) ); + else if( name == "avg" && argc >= 1 ) + { + const auto sum = std::accumulate( numArgs.begin(), numArgs.end(), 0.0 ); + return MakeValue( sum / static_cast( argc ) ); + } + + return MakeError( std::format( "Unknown function: {} with {} arguments", name, argc ) ); +} + +auto DOC_PROCESSOR::Process( const DOC& aDoc, VariableCallback aVariableCallback ) + -> std::pair + { + std::string result; + auto localErrors = ERROR_COLLECTOR{}; + EVAL_VISITOR evaluator{ std::move( aVariableCallback ), localErrors }; + bool hadErrors = aDoc.HasErrors(); + + for( const auto& node : aDoc.GetNodes() ) + { + switch( node->type ) + { + case NodeType::Text: + result += std::get( node->data ); + break; + + case NodeType::Calc: + { + const auto& calcData = std::get( node->data ); + auto evalResult = calcData.left->Accept( evaluator ); + + if( evalResult ) + result += VALUE_UTILS::ToString( evalResult.GetValue() ); + else + { + // Don't add error formatting to result - errors go to error vector only + // The higher level will return original input unchanged if there are errors + hadErrors = true; + } + break; + } + + default: + result += "[Unknown node type]"; + hadErrors = true; + break; + } + } + + return { std::move( result ), hadErrors || localErrors.HasErrors() }; +} + +auto DOC_PROCESSOR::ProcessWithDetails( const DOC& aDoc, VariableCallback aVariableCallback ) + -> std::tuple, bool> +{ + auto [result, hadErrors] = Process( aDoc, std::move( aVariableCallback ) ); + auto allErrors = aDoc.GetErrors(); + + return { std::move( result ), std::move( allErrors ), hadErrors }; +} + +} // namespace calc_parser diff --git a/common/text_eval/text_eval_wrapper.cpp b/common/text_eval/text_eval_wrapper.cpp new file mode 100644 index 0000000000..df713db35d --- /dev/null +++ b/common/text_eval/text_eval_wrapper.cpp @@ -0,0 +1,2068 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include + +// Include the KiCad common functionality +#include +#include +#include // Parser types +#include +#include // Centralized unit registry + +namespace KI_EVAL +{ + +#ifdef __GNUC__ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-variable" +#pragma GCC diagnostic ignored "-Wsign-compare" +#pragma GCC diagnostic ignored "-Wimplicit-fallthrough" +#endif + +#include + +#ifdef __GNUC__ +#pragma GCC diagnostic pop +#endif +} + +#include +#include +#include +#include + +// // Token type enum matching the generated parser +enum class TextEvalToken : int +{ + ENDS = KI_EVAL_LT - 1, + LT = KI_EVAL_LT, + GT = KI_EVAL_GT, + LE = KI_EVAL_LE, + GE = KI_EVAL_GE, + EQ = KI_EVAL_EQ, + NE = KI_EVAL_NE, + PLUS = KI_EVAL_PLUS, + MINUS = KI_EVAL_MINUS, + MULTIPLY = KI_EVAL_MULTIPLY, + DIVIDE = KI_EVAL_DIVIDE, + MODULO = KI_EVAL_MODULO, + UMINUS = KI_EVAL_UMINUS, + POWER = KI_EVAL_POWER, + COMMA = KI_EVAL_COMMA, + TEXT = KI_EVAL_TEXT, + AT_OPEN = KI_EVAL_AT_OPEN, + CLOSE_BRACE = KI_EVAL_CLOSE_BRACE, + LPAREN = KI_EVAL_LPAREN, + RPAREN = KI_EVAL_RPAREN, + NUMBER = KI_EVAL_NUMBER, + STRING = KI_EVAL_STRING, + IDENTIFIER = KI_EVAL_IDENTIFIER, + DOLLAR_OPEN = KI_EVAL_DOLLAR_OPEN, +}; + +// UTF-8 <-> UTF-32 conversion utilities +namespace utf8_utils { + + // Concept for UTF-8 byte validation + template + concept Utf8Byte = std::same_as || std::same_as || std::same_as; + + // UTF-8 validation and conversion + class UTF8_CONVERTER + { + private: + // UTF-8 byte classification using bit operations + static constexpr bool is_ascii( std::byte b ) noexcept + { + return ( b & std::byte{ 0x80 } ) == std::byte{ 0x00 }; + } + + static constexpr bool is_continuation( std::byte b ) noexcept + { + return ( b & std::byte{ 0xC0 } ) == std::byte{ 0x80 }; + } + + static constexpr int sequence_length( std::byte first ) noexcept + { + if( is_ascii( first ) ) + return 1; + if( ( first & std::byte{ 0xE0 } ) == std::byte{ 0xC0 } ) + return 2; + if( ( first & std::byte{ 0xF0 } ) == std::byte{ 0xE0 } ) + return 3; + if( ( first & std::byte{ 0xF8 } ) == std::byte{ 0xF0 } ) + return 4; + return 0; // Invalid + } + + public: + // Convert UTF-8 string to UTF-32 codepoints using C++20 ranges + static std::u32string to_utf32( std::string_view utf8 ) + { + std::u32string result; + result.reserve( utf8.size() ); // Conservative estimate + + auto bytes = std::as_bytes( std::span{ utf8.data(), utf8.size() } ); + + for( size_t i = 0; i < bytes.size(); ) + { + std::byte first = bytes[i]; + int len = sequence_length( first ); + + if( len == 0 || i + len > bytes.size() ) + { + // Invalid sequence - insert replacement character + result.push_back( U'\uFFFD' ); + i++; + continue; + } + + char32_t codepoint = 0; + + switch( len ) + { + case 1: codepoint = std::to_integer( first ); break; + + case 2: + { + if( !is_continuation( bytes[i + 1] ) ) + { + result.push_back( U'\uFFFD' ); + i++; + continue; + } + codepoint = ( std::to_integer( first & std::byte{ 0x1F } ) << 6 ) + | std::to_integer( bytes[i + 1] & std::byte{ 0x3F } ); + break; + } + + case 3: + { + if( !is_continuation( bytes[i + 1] ) || !is_continuation( bytes[i + 2] ) ) + { + result.push_back( U'\uFFFD' ); + i++; + continue; + } + codepoint = ( std::to_integer( first & std::byte{ 0x0F } ) << 12 ) + | ( std::to_integer( bytes[i + 1] & std::byte{ 0x3F } ) << 6 ) + | std::to_integer( bytes[i + 2] & std::byte{ 0x3F } ); + break; + } + + case 4: + { + if( !is_continuation( bytes[i + 1] ) || !is_continuation( bytes[i + 2] ) + || !is_continuation( bytes[i + 3] ) ) + { + result.push_back( U'\uFFFD' ); + i++; + continue; + } + codepoint = ( std::to_integer( first & std::byte{ 0x07 } ) << 18 ) + | ( std::to_integer( bytes[i + 1] & std::byte{ 0x3F } ) << 12 ) + | ( std::to_integer( bytes[i + 2] & std::byte{ 0x3F } ) << 6 ) + | std::to_integer( bytes[i + 3] & std::byte{ 0x3F } ); + break; + } + } + + // Validate codepoint range + if( codepoint > 0x10FFFF || ( codepoint >= 0xD800 && codepoint <= 0xDFFF ) ) + { + result.push_back( U'\uFFFD' ); // Replacement character + } + else if( len == 2 && codepoint < 0x80 ) + { + result.push_back( U'\uFFFD' ); // Overlong encoding + } + else if( len == 3 && codepoint < 0x800 ) + { + result.push_back( U'\uFFFD' ); // Overlong encoding + } + else if( len == 4 && codepoint < 0x10000 ) + { + result.push_back( U'\uFFFD' ); // Overlong encoding + } + else + { + result.push_back( codepoint ); + } + + i += len; + } + + return result; + } + + // Convert UTF-32 to UTF-8 + static std::string to_utf8( std::u32string_view utf32 ) + { + std::string result; + result.reserve( utf32.size() * 4 ); // Maximum possible size + + for( char32_t cp : utf32 ) + { + if( cp <= 0x7F ) + { + // 1-byte sequence + result.push_back( static_cast( cp ) ); + } + else if( cp <= 0x7FF ) + { + // 2-byte sequence + result.push_back( static_cast( 0xC0 | ( cp >> 6 ) ) ); + result.push_back( static_cast( 0x80 | ( cp & 0x3F ) ) ); + } + else if( cp <= 0xFFFF ) + { + // 3-byte sequence + if( cp >= 0xD800 && cp <= 0xDFFF ) + { + // Surrogate pair - invalid in UTF-32 + result.append( "\uFFFD" ); // Replacement character in UTF-8 + } + else + { + result.push_back( static_cast( 0xE0 | ( cp >> 12 ) ) ); + result.push_back( static_cast( 0x80 | ( ( cp >> 6 ) & 0x3F ) ) ); + result.push_back( static_cast( 0x80 | ( cp & 0x3F ) ) ); + } + } + else if( cp <= 0x10FFFF ) + { + // 4-byte sequence + result.push_back( static_cast( 0xF0 | ( cp >> 18 ) ) ); + result.push_back( static_cast( 0x80 | ( ( cp >> 12 ) & 0x3F ) ) ); + result.push_back( static_cast( 0x80 | ( ( cp >> 6 ) & 0x3F ) ) ); + result.push_back( static_cast( 0x80 | ( cp & 0x3F ) ) ); + } + else + { + // Invalid codepoint + result.append( "\uFFFD" ); // Replacement character in UTF-8 + } + } + + return result; + } + }; + + template + concept UnicodeCodepoint = std::same_as; + + struct CHARACTER_CLASSIFIER { + static constexpr bool is_whitespace(UnicodeCodepoint auto cp) noexcept { + // Unicode whitespace categories + return cp == U' ' || cp == U'\t' || cp == U'\r' || cp == U'\n' || + cp == U'\f' || cp == U'\v' || cp == U'\u00A0' || // Non-breaking space + cp == U'\u2000' || cp == U'\u2001' || cp == U'\u2002' || cp == U'\u2003' || + cp == U'\u2004' || cp == U'\u2005' || cp == U'\u2006' || cp == U'\u2007' || + cp == U'\u2008' || cp == U'\u2009' || cp == U'\u200A' || cp == U'\u2028' || + cp == U'\u2029' || cp == U'\u202F' || cp == U'\u205F' || cp == U'\u3000'; + } + + static constexpr bool is_digit(UnicodeCodepoint auto cp) noexcept { + return cp >= U'0' && cp <= U'9'; + } + + static constexpr bool is_ascii_alpha(UnicodeCodepoint auto cp) noexcept { + return (cp >= U'a' && cp <= U'z') || (cp >= U'A' && cp <= U'Z'); + } + + static constexpr bool is_alpha(UnicodeCodepoint auto cp) noexcept { + // Basic Latin + extended Unicode letter ranges + return is_ascii_alpha(cp) || (cp >= 0x80 && cp <= 0x10FFFF && cp != 0xFFFD); + } + + static constexpr bool is_alnum(UnicodeCodepoint auto cp) noexcept { + return is_alpha(cp) || is_digit(cp); + } + }; + + struct SI_PREFIX_HANDLER + { + struct PREFIX + { + char32_t symbol; + double multiplier; + }; + + static constexpr std::array prefixes = { { + {U'a', 1e-18}, + {U'f', 1e-15}, + {U'p', 1e-12}, + {U'n', 1e-9}, + {U'u', 1e-6}, {U'µ', 1e-6}, {U'μ', 1e-6}, // Various micro symbols + {U'm', 1e-3}, + {U'k', 1e3}, {U'K', 1e3}, + {U'M', 1e6}, + {U'G', 1e9}, + {U'T', 1e12}, + {U'P', 1e15}, + {U'E', 1e18} + } }; + + static constexpr bool is_si_prefix( UnicodeCodepoint auto cp ) noexcept + { + return std::ranges::any_of( prefixes, + [cp]( const PREFIX& p ) + { + return p.symbol == cp; + } ); + } + + static constexpr double get_multiplier( UnicodeCodepoint auto cp ) noexcept + { + auto it = std::ranges::find_if( prefixes, + [cp]( const PREFIX& p ) + { + return p.symbol == cp; + } ); + return it != prefixes.end() ? it->multiplier : 1.0; + } + }; +} + +// Unit conversion utilities for the text evaluator +namespace KIEVAL_UNIT_CONV { + + // Internal unit enum matching NUMERIC_EVALUATOR + enum class Unit { + Invalid, + UM, + MM, + CM, + Inch, + Mil, + Degrees, + SI, + Femtoseconds, + Picoseconds, + PsPerInch, + PsPerCm, + PsPerMm + }; + + // Convert EDA_UNITS to internal Unit enum + Unit edaUnitsToInternal( EDA_UNITS aUnits ) + { + switch( aUnits ) + { + case EDA_UNITS::MM: return Unit::MM; + case EDA_UNITS::MILS: return Unit::Mil; + case EDA_UNITS::INCH: return Unit::Inch; + case EDA_UNITS::DEGREES: return Unit::Degrees; + case EDA_UNITS::FS: return Unit::Femtoseconds; + case EDA_UNITS::PS: return Unit::Picoseconds; + case EDA_UNITS::PS_PER_INCH: return Unit::PsPerInch; + case EDA_UNITS::PS_PER_CM: return Unit::PsPerCm; + case EDA_UNITS::PS_PER_MM: return Unit::PsPerMm; + case EDA_UNITS::UM: return Unit::UM; + case EDA_UNITS::CM: return Unit::CM; + case EDA_UNITS::UNSCALED: return Unit::SI; + default: return Unit::MM; + } + } + + // Parse unit from string using centralized registry + Unit parseUnit( const std::string& aUnitStr ) + { + auto evalUnit = text_eval_units::UnitRegistry::parseUnit( aUnitStr ); + + // Convert text_eval_units::Unit to KIEVAL_UNIT_CONV::Unit + switch( evalUnit ) + { + case text_eval_units::Unit::MM: return Unit::MM; + case text_eval_units::Unit::CM: return Unit::CM; + case text_eval_units::Unit::IN: return Unit::Inch; + case text_eval_units::Unit::INCH_QUOTE: return Unit::Inch; + case text_eval_units::Unit::MIL: return Unit::Mil; + case text_eval_units::Unit::THOU: return Unit::Mil; + case text_eval_units::Unit::UM: return Unit::UM; + case text_eval_units::Unit::DEG: return Unit::Degrees; + case text_eval_units::Unit::DEGREE_SYMBOL: return Unit::Degrees; + case text_eval_units::Unit::PS: return Unit::Picoseconds; + case text_eval_units::Unit::FS: return Unit::Femtoseconds; + case text_eval_units::Unit::PS_PER_IN: return Unit::PsPerInch; + case text_eval_units::Unit::PS_PER_CM: return Unit::PsPerCm; + case text_eval_units::Unit::PS_PER_MM: return Unit::PsPerMm; + default: return Unit::Invalid; + } + } + + // Get conversion factor from one unit to another (based on numeric_evaluator logic) + double getConversionFactor( Unit aFromUnit, Unit aToUnit ) + { + if( aFromUnit == aToUnit ) + return 1.0; + + // Convert to MM first, then to target unit + double toMM = 1.0; + switch( aFromUnit ) + { + case Unit::Inch: toMM = 25.4; break; + case Unit::Mil: toMM = 25.4 / 1000.0; break; + case Unit::UM: toMM = 1.0 / 1000.0; break; + case Unit::MM: toMM = 1.0; break; + case Unit::CM: toMM = 10.0; break; + default: return 1.0; // No conversion for other units + } + + double fromMM = 1.0; + switch( aToUnit ) + { + case Unit::Inch: fromMM = 1.0 / 25.4; break; + case Unit::Mil: fromMM = 1000.0 / 25.4; break; + case Unit::UM: fromMM = 1000.0; break; + case Unit::MM: fromMM = 1.0; break; + case Unit::CM: fromMM = 1.0 / 10.0; break; + default: return 1.0; // No conversion for other units + } + + return toMM * fromMM; + } + + // Convert a value with units to the default units using centralized registry + double convertToDefaultUnits( double aValue, const std::string& aUnitStr, EDA_UNITS aDefaultUnits ) + { + return text_eval_units::UnitRegistry::convertToEdaUnits( aValue, aUnitStr, aDefaultUnits ); + } +} + + +class KIEVAL_TEXT_TOKENIZER +{ +private: + enum class TOKENIZER_CONTEXT + { + TEXT, // Regular text content - alphabetic should be TEXT tokens + EXPRESSION // Inside @{...} or ${...} - alphabetic should be IDENTIFIER tokens + }; + + std::u32string m_text; + size_t m_pos{ 0 }; + size_t m_line{ 1 }; + size_t m_column{ 1 }; + TOKENIZER_CONTEXT m_context{ TOKENIZER_CONTEXT::TEXT }; + int m_braceNestingLevel{ 0 }; // Track nesting level of expressions + calc_parser::ERROR_COLLECTOR* m_errorCollector{ nullptr }; + EDA_UNITS m_defaultUnits{ EDA_UNITS::MM }; // Add default units for conversion + + using CLASSIFIER = utf8_utils::CHARACTER_CLASSIFIER; + using SI_HANDLER = utf8_utils::SI_PREFIX_HANDLER; + + [[nodiscard]] constexpr char32_t current_char() const noexcept + { + return m_pos < m_text.size() ? m_text[m_pos] : U'\0'; + } + + [[nodiscard]] constexpr char32_t peek_char( size_t offset = 1 ) const noexcept + { + size_t peek_pos = m_pos + offset; + return peek_pos < m_text.size() ? m_text[peek_pos] : U'\0'; + } + + constexpr void advance_position( size_t count = 1 ) noexcept + { + for( size_t i = 0; i < count && m_pos < m_text.size(); ++i ) + { + if( m_text[m_pos] == U'\n' ) + { + ++m_line; + m_column = 1; + } + else + { + ++m_column; + } + ++m_pos; + } + } + + void skip_whitespace() noexcept + { + while( m_pos < m_text.size() && CLASSIFIER::is_whitespace( current_char() ) ) + advance_position(); + } + + void add_error( std::string_view message ) const + { + if( m_errorCollector ) + { + auto error_msg = std::format( "Line {}, Column {}: {}", m_line, m_column, message ); + m_errorCollector->AddError( error_msg ); + } + } + + [[nodiscard]] static calc_parser::TOKEN_TYPE make_string_token( std::string value ) noexcept + { + calc_parser::TOKEN_TYPE token{}; + token.isString = true; + std::strncpy( token.text, value.c_str(), sizeof( token.text ) - 1 ); + token.text[sizeof( token.text ) - 1] = '\0'; + token.dValue = 0.0; + return token; + } + + [[nodiscard]] static constexpr calc_parser::TOKEN_TYPE make_number_token( double value ) noexcept + { + calc_parser::TOKEN_TYPE token{}; + token.isString = false; + token.dValue = value; + return token; + } + + [[nodiscard]] calc_parser::TOKEN_TYPE parse_string_literal( char32_t quote_char ) + { + advance_position(); // Skip opening quote + + std::u32string content; + content.reserve( 64 ); // Reasonable default + + while( m_pos < m_text.size() && current_char() != quote_char ) + { + char32_t c = current_char(); + + if( c == U'\\' && m_pos + 1 < m_text.size() ) + { + char32_t escaped = peek_char(); + advance_position( 2 ); + + switch( escaped ) + { + case U'n': content.push_back( U'\n' ); break; + case U't': content.push_back( U'\t' ); break; + case U'r': content.push_back( U'\r' ); break; + case U'\\': content.push_back( U'\\' ); break; + case U'"': content.push_back( U'"' ); break; + case U'\'': content.push_back( U'\'' ); break; + case U'0': content.push_back( U'\0' ); break; + case U'x': + { + // Hexadecimal escape \xHH + std::u32string hex; + + for( int i = 0; i < 2 && m_pos < m_text.size(); ++i ) + { + char32_t hex_char = current_char(); + if( ( hex_char >= U'0' && hex_char <= U'9' ) + || ( hex_char >= U'A' && hex_char <= U'F' ) + || ( hex_char >= U'a' && hex_char <= U'f' ) ) + { + hex.push_back( hex_char ); + advance_position(); + } + else + { + break; + } + } + + if( !hex.empty() ) + { + try + { + auto hex_str = utf8_utils::UTF8_CONVERTER::to_utf8( hex ); + auto value = std::stoul( hex_str, nullptr, 16 ); + + if( value <= 0x10FFFF ) + content.push_back( static_cast( value ) ); + else + content.push_back( U'\uFFFD' ); + } + catch( ... ) + { + content.push_back( U'\uFFFD' ); + } + } + else + { + content.append( U"\\x" ); + } + + break; + } + default: + content.push_back( U'\\' ); + content.push_back( escaped ); + break; + } + } + else if( c == U'\n' ) + { + add_error( "Unterminated string literal" ); + break; + } + else + { + content.push_back( c ); + advance_position(); + } + } + + if (m_pos < m_text.size() && current_char() == quote_char) { + advance_position(); // Skip closing quote + } else { + add_error("Missing closing quote in string literal"); + } + + return make_string_token(utf8_utils::UTF8_CONVERTER::to_utf8(content)); + } + + [[nodiscard]] calc_parser::TOKEN_TYPE parse_number() + { + std::u32string number_text; + number_text.reserve( 32 ); + + bool has_decimal = false; + double multiplier = 1.0; + + // Parse integer part + while( m_pos < m_text.size() && CLASSIFIER::is_digit( current_char() ) ) + { + number_text.push_back( current_char() ); + advance_position(); + } + + // Handle decimal point, SI prefix, or unit suffix + if( m_pos < m_text.size() ) + { + char32_t c = current_char(); + + // Only treat comma as decimal separator in text context, not expression context + // This prevents comma from interfering with function argument separation + if( c == U'.' || ( c == U',' && m_context != TOKENIZER_CONTEXT::EXPRESSION ) ) + { + number_text.push_back( U'.' ); + has_decimal = true; + advance_position(); + } + else if( m_context == TOKENIZER_CONTEXT::EXPRESSION && CLASSIFIER::is_alpha( c ) ) + { + // In expression context, check for unit first before SI prefix (unit strings are longer) + // Look ahead to see if we have a complete unit string + std::u32string potential_unit; + size_t temp_pos = m_pos; + + while( temp_pos < m_text.size() ) + { + char32_t unit_char = m_text[temp_pos]; + + if( CLASSIFIER::is_alpha( unit_char ) || unit_char == U'"' || unit_char == U'\'' ) + { + potential_unit.push_back( unit_char ); + temp_pos++; + } + else + { + break; + } + } + + // Check if we have a valid unit + if( !potential_unit.empty() ) + { + std::string unit_str = utf8_utils::UTF8_CONVERTER::to_utf8( potential_unit ); + KIEVAL_UNIT_CONV::Unit parsed_unit = KIEVAL_UNIT_CONV::parseUnit( unit_str ); + + if( parsed_unit != KIEVAL_UNIT_CONV::Unit::Invalid ) + { + // This is a valid unit - don't treat the first character as SI prefix + // The unit parsing will happen later + } + else if( SI_HANDLER::is_si_prefix( c ) && !has_decimal ) + { + // Not a valid unit, so treat as SI prefix + multiplier = SI_HANDLER::get_multiplier( c ); + number_text.push_back( U'.' ); + has_decimal = true; + advance_position(); + } + } + else if( SI_HANDLER::is_si_prefix( c ) && !has_decimal ) + { + // No alphabetic characters following, so treat as SI prefix + multiplier = SI_HANDLER::get_multiplier( c ); + number_text.push_back( U'.' ); + has_decimal = true; + advance_position(); + } + } + else if( SI_HANDLER::is_si_prefix( c ) && !has_decimal ) + { + // In text context, treat as SI prefix + multiplier = SI_HANDLER::get_multiplier( c ); + number_text.push_back( U'.' ); + has_decimal = true; + advance_position(); + } + } + + // Parse fractional part + while (m_pos < m_text.size() && CLASSIFIER::is_digit(current_char())) { + number_text.push_back(current_char()); + advance_position(); + } + + // Convert to double safely + auto number_str = utf8_utils::UTF8_CONVERTER::to_utf8( number_text ); + double value = 0.0; + + try + { + if( !number_str.empty() && number_str != "." ) + { + auto result = fast_float::from_chars( number_str.data(), number_str.data() + number_str.size(), value ); + + if( result.ec != std::errc() || result.ptr != number_str.data() + number_str.size() ) + throw std::invalid_argument( std::format( "Cannot convert '{}' to number", number_str ) ); + + value *= multiplier; + + if( !std::isfinite( value ) ) + { + add_error( "Number out of range" ); + value = 0.0; + } + } + } + catch( const std::exception& e ) + { + add_error( std::format( "Invalid number format: {}", e.what() ) ); + value = 0.0; + } + + // Look for unit suffix + if( m_pos < m_text.size() && m_context == TOKENIZER_CONTEXT::EXPRESSION ) + { + // Skip any whitespace between number and unit + size_t whitespace_start = m_pos; + while( m_pos < m_text.size() && CLASSIFIER::is_whitespace( current_char() ) ) + { + advance_position(); + } + + // Parse potential unit suffix + std::u32string unit_text; + + // Look ahead to parse potential unit (letters, quotes, etc.) + while( m_pos < m_text.size() ) + { + char32_t c = current_char(); + + // Unit characters: letters, quotes for inches + if( CLASSIFIER::is_alpha( c ) || c == U'"' || c == U'\'' ) + { + unit_text.push_back( c ); + advance_position(); + } + else + { + break; + } + } + + if( !unit_text.empty() ) + { + // Convert unit text to string and try to parse it + std::string unit_str = utf8_utils::UTF8_CONVERTER::to_utf8( unit_text ); + KIEVAL_UNIT_CONV::Unit parsed_unit = KIEVAL_UNIT_CONV::parseUnit( unit_str ); + + if( parsed_unit != KIEVAL_UNIT_CONV::Unit::Invalid ) + { + // Successfully parsed unit - convert value to default units + double converted_value = KIEVAL_UNIT_CONV::convertToDefaultUnits( value, unit_str, m_defaultUnits ); + value = converted_value; + } + else + { + // Not a valid unit - backtrack to before the whitespace + m_pos = whitespace_start; + } + } + else + { + // No unit found - backtrack to before the whitespace + m_pos = whitespace_start; + } + } + + return make_number_token(value); + } + + [[nodiscard]] calc_parser::TOKEN_TYPE parse_identifier() { + std::u32string identifier; + identifier.reserve(64); + + while (m_pos < m_text.size() && + (CLASSIFIER::is_alnum(current_char()) || current_char() == U'_')) { + identifier.push_back(current_char()); + advance_position(); + } + + return make_string_token(utf8_utils::UTF8_CONVERTER::to_utf8(identifier)); + } + + [[nodiscard]] calc_parser::TOKEN_TYPE parse_text_content() { + std::u32string text; + text.reserve(256); + + while (m_pos < m_text.size()) { + char32_t current = current_char(); + char32_t next = peek_char(); + + // Stop at special sequences + if ((current == U'@' && next == U'{') || + (current == U'$' && next == U'{')) { + break; + } + + text.push_back(current); + advance_position(); + } + + return make_string_token(utf8_utils::UTF8_CONVERTER::to_utf8(text)); + } + +public: + explicit KIEVAL_TEXT_TOKENIZER( std::string_view input, calc_parser::ERROR_COLLECTOR* error_collector = nullptr, + EDA_UNITS default_units = EDA_UNITS::MM ) : + m_errorCollector( error_collector ), + m_defaultUnits( default_units ) + { + m_text = utf8_utils::UTF8_CONVERTER::to_utf32( input ); + } + + [[nodiscard]] TextEvalToken get_next_token( calc_parser::TOKEN_TYPE& token_value ) + { + token_value = calc_parser::TOKEN_TYPE{}; + + if( m_pos >= m_text.size() ) + { + return TextEvalToken::ENDS; + } + + // Only skip whitespace in expression context + if( m_context == TOKENIZER_CONTEXT::EXPRESSION ) + { + skip_whitespace(); + + if( m_pos >= m_text.size() ) + { + return TextEvalToken::ENDS; + } + } + + char32_t current = current_char(); + char32_t next = peek_char(); + + // Multi-character tokens that switch to expression context + if( current == U'@' && next == U'{' ) + { + advance_position( 2 ); + m_context = TOKENIZER_CONTEXT::EXPRESSION; // Switch to expression context + m_braceNestingLevel++; // Increment nesting level + token_value = make_string_token( "@{" ); + return TextEvalToken::AT_OPEN; + } + + if( current == U'$' && next == U'{' ) + { + advance_position( 2 ); + m_context = TOKENIZER_CONTEXT::EXPRESSION; // Switch to expression context + m_braceNestingLevel++; // Increment nesting level + token_value = make_string_token( "${" ); + return TextEvalToken::DOLLAR_OPEN; + } + + // Handle closing brace specially to manage context correctly + if( current == U'}' ) + { + advance_position(); + m_braceNestingLevel--; // Decrement nesting level + if( m_braceNestingLevel <= 0 ) + { + m_braceNestingLevel = 0; // Clamp to zero + m_context = TOKENIZER_CONTEXT::TEXT; // Switch back to text context only when fully unnested + } + token_value = make_string_token( "}" ); + return TextEvalToken::CLOSE_BRACE; + } + + // Multi-character comparison operators + if( current == U'<' && next == U'=' ) + { + advance_position( 2 ); + token_value = make_string_token( "<=" ); + return TextEvalToken::LE; + } + if( current == U'>' && next == U'=' ) + { + advance_position( 2 ); + token_value = make_string_token( ">=" ); + return TextEvalToken::GE; + } + if( current == U'=' && next == U'=' ) + { + advance_position( 2 ); + token_value = make_string_token( "==" ); + return TextEvalToken::EQ; + } + if( current == U'!' && next == U'=' ) + { + advance_position( 2 ); + token_value = make_string_token( "!=" ); + return TextEvalToken::NE; + } + + // Single character tokens using structured binding + // Single character tokens (only in expression context) + if( m_context == TOKENIZER_CONTEXT::EXPRESSION ) + { + static constexpr std::array, 11> single_char_tokens{{ + {U'(', TextEvalToken::LPAREN}, + {U')', TextEvalToken::RPAREN}, + {U'+', TextEvalToken::PLUS}, + {U'-', TextEvalToken::MINUS}, + {U'*', TextEvalToken::MULTIPLY}, + {U'/', TextEvalToken::DIVIDE}, + {U'%', TextEvalToken::MODULO}, + {U'^', TextEvalToken::POWER}, + {U',', TextEvalToken::COMMA}, + {U'<', TextEvalToken::LT}, + {U'>', TextEvalToken::GT} + }}; + + if( auto it = std::ranges::find_if( single_char_tokens, + [current]( const auto& pair ) { return pair.first == current; } ); + it != single_char_tokens.end() ) + { + advance_position(); + token_value = make_string_token( utf8_utils::UTF8_CONVERTER::to_utf8( std::u32string{ current } ) ); + return it->second; + } + } + + // Complex tokens + if( current == U'"' || current == U'\'' ) + { + token_value = parse_string_literal( current ); + return TextEvalToken::STRING; + } + + if( CLASSIFIER::is_digit( current ) || ( current == U'.' && CLASSIFIER::is_digit( next ) ) ) + { + token_value = parse_number(); + return TextEvalToken::NUMBER; + } + + // Context-aware handling of alphabetic content + if( CLASSIFIER::is_alpha( current ) || current == U'_' ) + { + if( m_context == TOKENIZER_CONTEXT::EXPRESSION ) + { + // In expression context, alphabetic content is an identifier + token_value = parse_identifier(); + return TextEvalToken::IDENTIFIER; + } + else + { + // In text context, alphabetic content is part of regular text + token_value = parse_text_content(); + return TextEvalToken::TEXT; + } + } + + // Default to text content + token_value = parse_text_content(); + return token_value.text[0] == U'\0' ? TextEvalToken::ENDS : TextEvalToken::TEXT; + } + + [[nodiscard]] constexpr bool has_more_tokens() const noexcept { return m_pos < m_text.size(); } + [[nodiscard]] constexpr size_t get_line() const noexcept { return m_line; } + [[nodiscard]] constexpr size_t get_column() const noexcept { return m_column; } +}; + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( bool aClearVariablesOnEvaluate ) : + m_clearVariablesOnEvaluate( aClearVariablesOnEvaluate ), + m_useCustomCallback( false ), + m_defaultUnits( EDA_UNITS::MM ) // Default to millimeters +{ + m_lastErrors = std::make_unique(); +} + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( VariableCallback aVariableCallback, + bool aClearVariablesOnEvaluate ) : + m_clearVariablesOnEvaluate( aClearVariablesOnEvaluate ), + m_customCallback( std::move( aVariableCallback ) ), + m_useCustomCallback( true ), + m_defaultUnits( EDA_UNITS::MM ) // Default to millimeters +{ + m_lastErrors = std::make_unique(); +} + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( EDA_UNITS aUnits, bool aClearVariablesOnEvaluate ) : + m_clearVariablesOnEvaluate( aClearVariablesOnEvaluate ), + m_useCustomCallback( false ), + m_defaultUnits( aUnits ) +{ + m_lastErrors = std::make_unique(); +} + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( EDA_UNITS aUnits, VariableCallback aVariableCallback, + bool aClearVariablesOnEvaluate ) : + m_clearVariablesOnEvaluate( aClearVariablesOnEvaluate ), + m_customCallback( std::move( aVariableCallback ) ), + m_useCustomCallback( true ), + m_defaultUnits( aUnits ) +{ + m_lastErrors = std::make_unique(); +} + +EXPRESSION_EVALUATOR::~EXPRESSION_EVALUATOR() = default; + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( const EXPRESSION_EVALUATOR& aOther ) : + m_variables( aOther.m_variables ), + m_clearVariablesOnEvaluate( aOther.m_clearVariablesOnEvaluate ), + m_customCallback( aOther.m_customCallback ), + m_useCustomCallback( aOther.m_useCustomCallback ), + m_defaultUnits( aOther.m_defaultUnits ) +{ + m_lastErrors = std::make_unique(); + if( aOther.m_lastErrors ) + { + // Copy error state + for( const auto& error : aOther.m_lastErrors->GetErrors() ) + m_lastErrors->AddError( error ); + } +} + +EXPRESSION_EVALUATOR& EXPRESSION_EVALUATOR::operator=( const EXPRESSION_EVALUATOR& aOther ) +{ + if( this != &aOther ) + { + m_variables = aOther.m_variables; + m_clearVariablesOnEvaluate = aOther.m_clearVariablesOnEvaluate; + m_customCallback = aOther.m_customCallback; + m_useCustomCallback = aOther.m_useCustomCallback; + m_defaultUnits = aOther.m_defaultUnits; + + m_lastErrors = std::make_unique(); + if( aOther.m_lastErrors ) + { + for( const auto& error : aOther.m_lastErrors->GetErrors() ) + m_lastErrors->AddError( error ); + } + } + return *this; +} + +EXPRESSION_EVALUATOR::EXPRESSION_EVALUATOR( EXPRESSION_EVALUATOR&& aOther ) noexcept : + m_variables( std::move( aOther.m_variables ) ), + m_lastErrors( std::move( aOther.m_lastErrors ) ), + m_clearVariablesOnEvaluate( aOther.m_clearVariablesOnEvaluate ), + m_customCallback( std::move( aOther.m_customCallback ) ), + m_useCustomCallback( aOther.m_useCustomCallback ), + m_defaultUnits( aOther.m_defaultUnits ) +{ +} + +EXPRESSION_EVALUATOR& EXPRESSION_EVALUATOR::operator=( EXPRESSION_EVALUATOR&& aOther ) noexcept +{ + if( this != &aOther ) + { + m_variables = std::move( aOther.m_variables ); + m_lastErrors = std::move( aOther.m_lastErrors ); + m_clearVariablesOnEvaluate = aOther.m_clearVariablesOnEvaluate; + m_customCallback = std::move( aOther.m_customCallback ); + m_useCustomCallback = aOther.m_useCustomCallback; + m_defaultUnits = aOther.m_defaultUnits; + } + return *this; +} + +void EXPRESSION_EVALUATOR::SetVariableCallback( VariableCallback aCallback ) +{ + m_customCallback = std::move( aCallback ); + m_useCustomCallback = true; +} + +void EXPRESSION_EVALUATOR::ClearVariableCallback() +{ + m_customCallback = VariableCallback{}; + m_useCustomCallback = false; +} + +bool EXPRESSION_EVALUATOR::HasVariableCallback() const +{ + return m_useCustomCallback && m_customCallback; +} + +void EXPRESSION_EVALUATOR::SetDefaultUnits( EDA_UNITS aUnits ) +{ + m_defaultUnits = aUnits; +} + +EDA_UNITS EXPRESSION_EVALUATOR::GetDefaultUnits() const +{ + return m_defaultUnits; +} + +void EXPRESSION_EVALUATOR::SetVariable( const wxString& aName, double aValue ) +{ + std::string name = wxStringToStdString( aName ); + m_variables[name] = calc_parser::Value{ aValue }; +} + +void EXPRESSION_EVALUATOR::SetVariable( const wxString& aName, const wxString& aValue ) +{ + std::string name = wxStringToStdString( aName ); + std::string value = wxStringToStdString( aValue ); + m_variables[name] = calc_parser::Value{ value }; +} + +void EXPRESSION_EVALUATOR::SetVariable( const std::string& aName, const std::string& aValue ) +{ + m_variables[aName] = calc_parser::Value{ aValue }; +} + +bool EXPRESSION_EVALUATOR::RemoveVariable( const wxString& aName ) +{ + std::string name = wxStringToStdString( aName ); + return m_variables.erase( name ) > 0; +} + +void EXPRESSION_EVALUATOR::ClearVariables() +{ + m_variables.clear(); +} + +bool EXPRESSION_EVALUATOR::HasVariable( const wxString& aName ) const +{ + std::string name = wxStringToStdString( aName ); + return m_variables.find( name ) != m_variables.end(); +} + +wxString EXPRESSION_EVALUATOR::GetVariable( const wxString& aName ) const +{ + std::string name = wxStringToStdString( aName ); + auto it = m_variables.find( name ); + if( it != m_variables.end() ) + { + if( std::holds_alternative( it->second ) ) + { + double val = std::get( it->second ); + // Smart formatting - whole numbers don't need decimal places + if( val == std::floor( val ) && std::abs( val ) < 1e15 ) + return wxString::Format( "%.0f", val ); + else + return wxString::Format( "%g", val ); + } + else + { + return stdStringToWxString( std::get( it->second ) ); + } + } + return wxString{}; +} + +std::vector EXPRESSION_EVALUATOR::GetVariableNames() const +{ + std::vector names; + names.reserve( m_variables.size() ); + + for( const auto& [name, value] : m_variables ) + names.push_back( stdStringToWxString( name ) ); + + return names; +} + +void EXPRESSION_EVALUATOR::SetVariables( const std::unordered_map& aVariables ) +{ + for( const auto& [name, value] : aVariables ) + SetVariable( name, value ); +} + +void EXPRESSION_EVALUATOR::SetVariables( const std::unordered_map& aVariables ) +{ + for( const auto& [name, value] : aVariables ) + SetVariable( name, value ); +} + +wxString EXPRESSION_EVALUATOR::Evaluate( const wxString& aInput ) +{ + std::unordered_map emptyNumVars; + std::unordered_map emptyStringVars; + return Evaluate( aInput, emptyNumVars, emptyStringVars ); +} + +wxString EXPRESSION_EVALUATOR::Evaluate( const wxString& aInput, + const std::unordered_map& aTempVariables ) +{ + std::unordered_map emptyStringVars; + return Evaluate( aInput, aTempVariables, emptyStringVars ); +} + +wxString EXPRESSION_EVALUATOR::Evaluate( const wxString& aInput, + const std::unordered_map& aTempNumericVars, + const std::unordered_map& aTempStringVars ) +{ + // Clear previous errors + ClearErrors(); + + // Expand ${variable} patterns that are OUTSIDE of @{} expressions + wxString processedInput = expandVariablesOutsideExpressions( aInput, aTempNumericVars, aTempStringVars ); + + // Convert processed input to std::string + std::string input = wxStringToStdString( processedInput ); // Create combined callback for all variable sources + auto combinedCallback = createCombinedCallback( &aTempNumericVars, &aTempStringVars ); + + // Evaluate using parser + auto [result, hadErrors] = evaluateWithParser( input, combinedCallback ); + + // Update error state if evaluation had errors + if( hadErrors && !m_lastErrors ) + { + m_lastErrors = std::make_unique(); + } + if( hadErrors ) + { + m_lastErrors->AddError( "Evaluation failed" ); + } + + // Clear variables if requested + if( m_clearVariablesOnEvaluate ) + ClearVariables(); + + // Convert result back to wxString + return stdStringToWxString( result ); +} + +bool EXPRESSION_EVALUATOR::HasErrors() const +{ + return m_lastErrors && m_lastErrors->HasErrors(); +} + +wxString EXPRESSION_EVALUATOR::GetErrorSummary() const +{ + if( !m_lastErrors ) + return wxString{}; + + return stdStringToWxString( m_lastErrors->GetAllMessages() ); +} + +size_t EXPRESSION_EVALUATOR::GetErrorCount() const +{ + if( !m_lastErrors ) + return 0; + + return m_lastErrors->GetErrors().size(); +} + +std::vector EXPRESSION_EVALUATOR::GetErrors() const +{ + std::vector result; + + if( m_lastErrors ) + { + const auto& errors = m_lastErrors->GetErrors(); + result.reserve( errors.size() ); + + for( const auto& error : errors ) + result.push_back( stdStringToWxString( error ) ); + } + + return result; +} + +void EXPRESSION_EVALUATOR::ClearErrors() +{ + if( m_lastErrors ) + m_lastErrors->Clear(); +} + +void EXPRESSION_EVALUATOR::SetClearVariablesOnEvaluate( bool aEnable ) +{ + m_clearVariablesOnEvaluate = aEnable; +} + +bool EXPRESSION_EVALUATOR::GetClearVariablesOnEvaluate() const +{ + return m_clearVariablesOnEvaluate; +} + +bool EXPRESSION_EVALUATOR::TestExpression( const wxString& aExpression ) +{ + // Create a test input with the expression wrapped in @{} + wxString testInput = "@{" + aExpression + "}"; + + // Create a minimal callback that returns errors for all variables + auto testCallback = []( const std::string& aVarName ) -> calc_parser::Result + { + return calc_parser::MakeError( "Test mode - no variables available" ); + }; + + // Try to parse it + std::string input = wxStringToStdString( testInput ); + auto [result, hadErrors] = evaluateWithParser( input, testCallback ); + + // Check if there were parsing errors (ignore evaluation errors for undefined variables) + if( m_lastErrors ) + { + const auto& errors = m_lastErrors->GetErrors(); + // Filter out "Test mode - no variables available" errors, look for syntax errors + for( const auto& error : errors ) + { + if( error.find( "Syntax error" ) != std::string::npos || + error.find( "Parser failed" ) != std::string::npos ) + { + return false; // Found syntax error + } + } + } + + return true; // No syntax errors found +} + +size_t EXPRESSION_EVALUATOR::CountExpressions( const wxString& aInput ) const +{ + size_t count = 0; + size_t pos = 0; + + while( ( pos = aInput.find( "@{", pos ) ) != wxString::npos ) + { + count++; + pos += 2; // Move past "@{" + } + + return count; +} + +std::vector EXPRESSION_EVALUATOR::ExtractExpressions( const wxString& aInput ) const +{ + std::vector expressions; + size_t pos = 0; + + while( ( pos = aInput.find( "@{", pos ) ) != wxString::npos ) + { + size_t start = pos + 2; // Skip "@{" + size_t end = aInput.find( "}", start ); + + if( end != wxString::npos ) + { + wxString expression = aInput.substr( start, end - start ); + expressions.push_back( expression ); + pos = end + 1; + } + else + { + break; // No closing brace found + } + } + + return expressions; +} + +std::string EXPRESSION_EVALUATOR::wxStringToStdString( const wxString& aWxStr ) const +{ + return aWxStr.ToStdString( wxConvUTF8 ); +} + +wxString EXPRESSION_EVALUATOR::stdStringToWxString( const std::string& aStdStr ) const +{ + return wxString( aStdStr.c_str(), wxConvUTF8 ); +} + +wxString EXPRESSION_EVALUATOR::expandVariablesOutsideExpressions( + const wxString& aInput, + const std::unordered_map& aTempNumericVars, + const std::unordered_map& aTempStringVars ) const +{ + wxString result = aInput; + size_t pos = 0; + + // Track positions of @{} expressions to avoid substituting inside them + std::vector> expressionRanges; + + // Find all @{} expression ranges + while( (pos = result.find( "@{", pos )) != std::string::npos ) + { + size_t start = pos; + size_t braceCount = 1; + size_t searchPos = start + 2; // Skip "@{" + + // Find matching closing brace + while( searchPos < result.length() && braceCount > 0 ) + { + if( result[searchPos] == '{' ) + braceCount++; + else if( result[searchPos] == '}' ) + braceCount--; + searchPos++; + } + + if( braceCount == 0 ) + { + expressionRanges.emplace_back( start, searchPos ); // searchPos is after '}' + } + + pos = searchPos; + } + + // Now find and replace ${variable} patterns that are NOT inside @{} expressions + pos = 0; + while( (pos = result.find( "${", pos )) != std::string::npos ) + { + // Check if this ${} is inside any @{} expression + bool insideExpression = false; + for( const auto& range : expressionRanges ) + { + if( pos >= range.first && pos < range.second ) + { + insideExpression = true; + break; + } + } + + if( insideExpression ) + { + // Special case: if this variable is immediately followed by unit text, + // we should expand it to allow proper unit parsing + size_t closePos = result.find( "}", pos + 2 ); + if( closePos != std::string::npos ) + { + // Check what comes after the closing brace + size_t afterBrace = closePos + 1; + bool followedByUnit = false; + + if( afterBrace < result.length() ) + { + // Check if followed by any supported unit strings using centralized registry + const auto units = text_eval_units::UnitRegistry::getAllUnitStrings(); + for( const auto& unit : units ) + { + if( afterBrace + unit.length() <= result.length() && + result.substr( afterBrace, unit.length() ) == unit ) + { + followedByUnit = true; + break; + } + } + } + + if( !followedByUnit ) + { + pos += 2; // Skip this ${} since it's inside an expression and not followed by units + continue; + } + // If followed by units, continue with variable expansion below + } + else + { + pos += 2; // Invalid pattern, skip + continue; + } + } + + // Find the closing brace + size_t closePos = result.find( "}", pos + 2 ); + if( closePos == std::string::npos ) + { + pos += 2; // Invalid ${} pattern, skip + continue; + } + + // Extract variable name + wxString varName = result.substr( pos + 2, closePos - pos - 2 ); + wxString replacement; + bool found = false; + + // Check temporary string variables first + auto stringIt = aTempStringVars.find( varName ); + if( stringIt != aTempStringVars.end() ) + { + replacement = stringIt->second; + found = true; + } + else + { + // Check temporary numeric variables + auto numIt = aTempNumericVars.find( varName ); + if( numIt != aTempNumericVars.end() ) + { + replacement = wxString::FromDouble( numIt->second ); + found = true; + } + else + { + // Check instance variables + std::string stdVarName = wxStringToStdString( varName ); + auto instIt = m_variables.find( stdVarName ); + if( instIt != m_variables.end() ) + { + const calc_parser::Value& value = instIt->second; + if( std::holds_alternative( value ) ) + { + replacement = stdStringToWxString( std::get( value ) ); + found = true; + } + else if( std::holds_alternative( value ) ) + { + replacement = wxString::FromDouble( std::get( value ) ); + found = true; + } + } + } + } + + if( found ) + { + // Replace ${variable} with its value + result.replace( pos, closePos - pos + 1, replacement ); + pos += replacement.length(); + } + else + { + // Variable not found, record error but leave ${variable} unchanged + if( !m_lastErrors ) + m_lastErrors = std::make_unique(); + m_lastErrors->AddError( std::format( "Undefined variable: {}", wxStringToStdString( varName ) ) ); + pos = closePos + 1; + } + } + + return result; +} + +EXPRESSION_EVALUATOR::VariableCallback EXPRESSION_EVALUATOR::createCombinedCallback( + const std::unordered_map* aTempNumericVars, + const std::unordered_map* aTempStringVars ) const +{ + return [this, aTempNumericVars, aTempStringVars]( const std::string& aVarName ) -> calc_parser::Result + { + // Priority 1: Custom callback (if set) + if( m_useCustomCallback && m_customCallback ) + { + auto customResult = m_customCallback( aVarName ); + if( customResult.HasValue() ) + return customResult; + + // If custom callback returned an error, continue to fallback options + // unless the error indicates a definitive "not found" vs "lookup failed" + // For simplicity, we'll always try fallbacks + } + + // Priority 2: Temporary string variables + if( aTempStringVars ) + { + wxString wxVarName = stdStringToWxString( aVarName ); + if( auto it = aTempStringVars->find( wxVarName ); it != aTempStringVars->end() ) + { + std::string stdValue = wxStringToStdString( it->second ); + return calc_parser::MakeValue( stdValue ); + } + } + + // Priority 3: Temporary numeric variables + if( aTempNumericVars ) + { + wxString wxVarName = stdStringToWxString( aVarName ); + if( auto it = aTempNumericVars->find( wxVarName ); it != aTempNumericVars->end() ) + { + return calc_parser::MakeValue( it->second ); + } + } + + // Priority 4: Stored variables + if( auto it = m_variables.find( aVarName ); it != m_variables.end() ) + { + return calc_parser::MakeValue( it->second ); + } + + // Priority 5: Use KiCad's ExpandTextVars for system/project variables + try + { + wxString varName = stdStringToWxString( aVarName ); + wxString testString = wxString::Format( "${%s}", varName ); + + // Create a resolver that will return true if the variable was found + bool wasResolved = false; + std::function resolver = + [&wasResolved]( wxString* token ) -> bool + { + // If we get here, ExpandTextVars found the variable and wants to resolve it + // For our purposes, we just want to know if it exists, so return false + // to keep the original ${varname} format, and set our flag + wasResolved = true; + return false; // Don't replace, just detect + }; + + wxString expandedResult = ExpandTextVars( testString, &resolver ); + + if( wasResolved ) + { + // Variable exists in KiCad's system, now get its actual value + std::function valueResolver = + []( wxString* token ) -> bool + { + // Let ExpandTextVars resolve this normally + // We'll get the resolved value in token + return false; // Use default resolution + }; + + wxString resolvedValue = ExpandTextVars( testString, &valueResolver ); + + // Check if it was actually resolved (not still ${varname}) + if( resolvedValue != testString ) + { + std::string resolvedStd = wxStringToStdString( resolvedValue ); + + // Try to parse as number first + try + { + double numValue; + auto result = fast_float::from_chars( resolvedStd.data(), resolvedStd.data() + resolvedStd.size(), numValue ); + + if( result.ec != std::errc() || result.ptr != resolvedStd.data() + resolvedStd.size() ) + throw std::invalid_argument( std::format( "Cannot convert '{}' to number", resolvedStd ) ); + + return calc_parser::MakeValue( numValue ); + } + catch( ... ) + { + // Not a number, return as string + return calc_parser::MakeValue( resolvedStd ); + } + } + } + } + catch( const std::exception& e ) + { + // ExpandTextVars failed, continue to error + } + + // Priority 6: If custom callback was tried and failed, return its error + if( m_useCustomCallback && m_customCallback ) + { + return m_customCallback( aVarName ); // Return the original error + } + + // No variable found anywhere + return calc_parser::MakeError( + std::format( "Undefined variable: {}", aVarName ) ); + }; +} + +std::pair EXPRESSION_EVALUATOR::evaluateWithParser( + const std::string& aInput, + VariableCallback aVariableCallback) +{ + try { + // Try partial error recovery first + auto [partialResult, partialHadErrors] = evaluateWithPartialErrorRecovery(aInput, aVariableCallback); + + // If partial recovery made any progress (result differs from input), use it + if (partialResult != aInput) { + // Partial recovery made progress - always report errors collected during partial recovery + return {std::move(partialResult), partialHadErrors}; + } + + // If no progress was made, try original full parsing approach as fallback + return evaluateWithFullParser(aInput, std::move(aVariableCallback)); + + } catch (const std::bad_alloc&) { + if (m_lastErrors) { + m_lastErrors->AddError("Out of memory"); + } + return {aInput, true}; + } catch (const std::exception& e) { + if (m_lastErrors) { + m_lastErrors->AddError(std::format("Exception: {}", e.what())); + } + return {aInput, true}; + } +} + +std::pair EXPRESSION_EVALUATOR::evaluateWithPartialErrorRecovery( + const std::string& aInput, + VariableCallback aVariableCallback) +{ + std::string result = aInput; + bool hadAnyErrors = false; + size_t pos = 0; + + // Process expressions from right to left to avoid position shifts + std::vector> expressionRanges; + + // Find all expression ranges + while( ( pos = result.find( "@{", pos ) ) != std::string::npos ) + { + size_t start = pos; + size_t exprStart = pos + 2; // Skip "@{" + size_t braceCount = 1; + size_t searchPos = exprStart; + + // Find matching closing brace, handling nested braces + while( searchPos < result.length() && braceCount > 0 ) + { + if( result[searchPos] == '{' ) + { + braceCount++; + } + else if( result[searchPos] == '}' ) + { + braceCount--; + } + searchPos++; + } + + if( braceCount == 0 ) + { + size_t end = searchPos; // Position after the '}' + expressionRanges.emplace_back( start, end ); + pos = end; + } + else + { + pos = exprStart; // Skip this malformed expression + } + } + + // Process expressions from right to left to avoid position shifts + for( auto it = expressionRanges.rbegin(); it != expressionRanges.rend(); ++it ) + { + auto [start, end] = *it; + std::string fullExpr = result.substr( start, end - start ); + std::string innerExpr = result.substr( start + 2, end - start - 3 ); // Remove @{ and } + + // Try to evaluate this single expression + try + { + // Create a simple expression for evaluation + std::string testExpr = "@{" + innerExpr + "}"; + + // Create a temporary error collector to capture errors for this specific expression + auto tempErrors = std::make_unique(); + auto oldErrors = std::move( m_lastErrors ); + m_lastErrors = std::move( tempErrors ); + + // Use the full parser for this single expression + auto [evalResult, evalHadErrors] = evaluateWithFullParser( testExpr, aVariableCallback ); + + if( !evalHadErrors ) + { + // Successful evaluation, replace in result + result.replace( start, end - start, evalResult ); + } + else + { + // Expression failed - add a specific error for this expression + hadAnyErrors = true; + + // Restore main error collector and add error + if( !oldErrors ) + oldErrors = std::make_unique(); + + oldErrors->AddError( std::format( "Failed to evaluate expression: {}", fullExpr ) ); + } + + // Restore the main error collector + m_lastErrors = std::move( oldErrors ); + } + catch( ... ) + { + // Report exception as an error for this expression + if( !m_lastErrors ) + m_lastErrors = std::make_unique(); + + m_lastErrors->AddError( std::format( "Exception in expression: {}", fullExpr ) ); + hadAnyErrors = true; + } + } + + return { std::move( result ), hadAnyErrors }; +} + +std::pair EXPRESSION_EVALUATOR::evaluateWithFullParser( const std::string& aInput, + VariableCallback aVariableCallback ) +{ + if( aInput.empty() ) + { + return { std::string{}, false }; + } + + // RAII guard for error collector cleanup + struct ErrorCollectorGuard + { + ~ErrorCollectorGuard() { calc_parser::g_errorCollector = nullptr; } + } guard; + + try + { + // Clear previous errors + if( m_lastErrors ) + { + m_lastErrors->Clear(); + } + + // Set up error collector + calc_parser::g_errorCollector = m_lastErrors.get(); + + // Create tokenizer with default units + KIEVAL_TEXT_TOKENIZER tokenizer{ aInput, m_lastErrors.get(), m_defaultUnits }; + + // Create parser deleter function + auto parser_deleter = []( void* p ) + { + KI_EVAL::ParseFree( p, free ); + }; + + // Allocate parser with RAII cleanup + std::unique_ptr parser{ KI_EVAL::ParseAlloc( malloc ), parser_deleter }; + + if( !parser ) + { + if( m_lastErrors ) + { + m_lastErrors->AddError( "Failed to allocate parser" ); + } + return { aInput, true }; + } + + // Parse document + calc_parser::DOC* document = nullptr; + + calc_parser::TOKEN_TYPE token_value; + TextEvalToken token_type; + + do + { + token_type = tokenizer.get_next_token( token_value ); + + // Send token to parser + KI_EVAL::Parse( parser.get(), static_cast( token_type ), token_value, &document ); + + // Early exit on errors + if( m_lastErrors && m_lastErrors->HasErrors() ) + { + break; + } + + } while( token_type != TextEvalToken::ENDS && tokenizer.has_more_tokens() ); + + // Finalize parsing + KI_EVAL::Parse( parser.get(), static_cast( TextEvalToken::ENDS ), calc_parser::TOKEN_TYPE{}, &document ); + + // Process document if parsing succeeded + if( document && ( !m_lastErrors || !m_lastErrors->HasErrors() ) ) + { + calc_parser::DOC_PROCESSOR processor; + auto [result, had_errors] = processor.Process( *document, std::move( aVariableCallback ) ); + + // If processing had any evaluation errors, return original input unchanged + // This preserves the original expression syntax while still reporting errors + if( had_errors ) + { + delete document; + return { aInput, true }; + } + + delete document; + return { std::move( result ), had_errors }; + } + + // Cleanup and return original on error + delete document; + return { aInput, true }; + } + catch( const std::bad_alloc& ) + { + if( m_lastErrors ) + { + m_lastErrors->AddError( "Out of memory" ); + } + return { aInput, true }; + } + catch( const std::exception& e ) + { + if( m_lastErrors ) + { + m_lastErrors->AddError( std::format( "Exception: {}", e.what() ) ); + } + return { aInput, true }; + } +} + +NUMERIC_EVALUATOR_COMPAT::NUMERIC_EVALUATOR_COMPAT( EDA_UNITS aUnits ) + : m_evaluator( aUnits ), m_lastValid( false ) +{ +} + +NUMERIC_EVALUATOR_COMPAT::~NUMERIC_EVALUATOR_COMPAT() = default; + +void NUMERIC_EVALUATOR_COMPAT::Clear() +{ + m_lastInput.clear(); + m_lastResult.clear(); + m_lastValid = false; + m_evaluator.ClearErrors(); +} + +void NUMERIC_EVALUATOR_COMPAT::SetDefaultUnits( EDA_UNITS aUnits ) +{ + m_evaluator.SetDefaultUnits( aUnits ); +} + +void NUMERIC_EVALUATOR_COMPAT::LocaleChanged() +{ + // No-op: EXPRESSION_EVALUATOR handles locale properly internally +} + +bool NUMERIC_EVALUATOR_COMPAT::IsValid() const +{ + return m_lastValid; +} + +wxString NUMERIC_EVALUATOR_COMPAT::Result() const +{ + return m_lastResult; +} + +bool NUMERIC_EVALUATOR_COMPAT::Process( const wxString& aString ) +{ + m_lastInput = aString; + m_evaluator.ClearErrors(); + + // Convert bare variable names to ${variable} syntax for compatibility + // This allows NUMERIC_EVALUATOR-style variable access to work with EXPRESSION_EVALUATOR + wxString processedExpression = aString; + + // Get all variable names that are currently defined + auto varNames = m_evaluator.GetVariableNames(); + + // Sort variable names by length (longest first) to avoid partial replacements + std::sort( varNames.begin(), varNames.end(), + []( const wxString& a, const wxString& b ) { return a.length() > b.length(); } ); + + // Replace bare variable names with ${variable} syntax + for( const auto& varName : varNames ) + { + // Create a regex to match the variable name as a whole word + // This avoids replacing parts of other words + wxString pattern = "\\b" + varName + "\\b"; + wxString replacement = "${" + varName + "}"; + + // Simple string replacement (not regex for now to avoid complexity) + // Look for the variable name surrounded by non-alphanumeric characters + size_t pos = 0; + + while( ( pos = processedExpression.find( varName, pos ) ) != wxString::npos ) + { + // Check if this is a whole word (not part of another identifier) + bool isWholeWord = true; + + // Check character before + if( pos > 0 ) + { + wxChar before = processedExpression[pos - 1]; + if( wxIsalnum( before ) || before == '_' || before == '$' ) + isWholeWord = false; + } + + // Check character after + if( isWholeWord && pos + varName.length() < processedExpression.length() ) + { + wxChar after = processedExpression[pos + varName.length()]; + if( wxIsalnum( after ) || after == '_' ) + isWholeWord = false; + } + + if( isWholeWord ) + { + processedExpression.replace( pos, varName.length(), replacement ); + pos += replacement.length(); + } + else + { + pos += varName.length(); + } + } + } + + // Wrap the processed expression in @{...} syntax for EXPRESSION_EVALUATOR + wxString wrappedExpression = "@{" + processedExpression + "}"; + + m_lastResult = m_evaluator.Evaluate( wrappedExpression ); + m_lastValid = !m_evaluator.HasErrors(); + + // Additional check: if the result is exactly the wrapped expression, + // it means the expression wasn't evaluated (likely due to errors) + if( m_lastResult == wrappedExpression ) + { + m_lastValid = false; + m_lastResult = "NaN"; + } + + // If there were errors, set result to "NaN" to match NUMERIC_EVALUATOR behavior + if( !m_lastValid ) + { + m_lastResult = "NaN"; + return false; + } + + return true; +} + +wxString NUMERIC_EVALUATOR_COMPAT::OriginalText() const +{ + return m_lastInput; +} + +void NUMERIC_EVALUATOR_COMPAT::SetVar( const wxString& aString, double aValue ) +{ + m_evaluator.SetVariable( aString, aValue ); +} + +double NUMERIC_EVALUATOR_COMPAT::GetVar( const wxString& aString ) +{ + if( !m_evaluator.HasVariable( aString ) ) + return 0.0; + + wxString value = m_evaluator.GetVariable( aString ); + + // Try to convert to double + double result = 0.0; + + if( !value.ToDouble( &result ) ) + return 0.0; + + return result; +} + +void NUMERIC_EVALUATOR_COMPAT::RemoveVar( const wxString& aString ) +{ + m_evaluator.RemoveVariable( aString ); +} + +void NUMERIC_EVALUATOR_COMPAT::ClearVar() +{ + m_evaluator.ClearVariables(); +} \ No newline at end of file diff --git a/eeschema/sch_field.cpp b/eeschema/sch_field.cpp index 04a2c77d68..92236972ac 100644 --- a/eeschema/sch_field.cpp +++ b/eeschema/sch_field.cpp @@ -286,6 +286,9 @@ wxString SCH_FIELD::GetShownText( const SCH_SHEET_PATH* aPath, bool aAllowExtraT if( m_id == FIELD_T::SHEET_FILENAME && aAllowExtraText && !IsNameShown() ) text = _( "File:" ) + wxS( " " ) + text; + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + return text; } diff --git a/eeschema/sch_label.cpp b/eeschema/sch_label.cpp index 144a924ba5..853b4bb3ca 100644 --- a/eeschema/sch_label.cpp +++ b/eeschema/sch_label.cpp @@ -906,6 +906,9 @@ wxString SCH_LABEL_BASE::GetShownText( const SCH_SHEET_PATH* aPath, bool aAllowE text = ExpandTextVars( text, &textResolver ); } + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + return text; } diff --git a/eeschema/sch_text.cpp b/eeschema/sch_text.cpp index 63752b5d95..004e4678bf 100644 --- a/eeschema/sch_text.cpp +++ b/eeschema/sch_text.cpp @@ -355,6 +355,9 @@ wxString SCH_TEXT::GetShownText( const SCH_SHEET_PATH* aPath, bool aAllowExtraTe text = ExpandTextVars( text, &textResolver ); } + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + return text; } diff --git a/eeschema/sch_textbox.cpp b/eeschema/sch_textbox.cpp index 0f11b007c6..38f8a9076d 100644 --- a/eeschema/sch_textbox.cpp +++ b/eeschema/sch_textbox.cpp @@ -297,6 +297,9 @@ wxString SCH_TEXTBOX::GetShownText( const RENDER_SETTINGS* aSettings, const SCH_ text = ExpandTextVars( text, &textResolver ); } + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + VECTOR2I size = GetEnd() - GetStart(); int colWidth; diff --git a/include/eda_text.h b/include/eda_text.h index 16d6e0f698..a505d855da 100644 --- a/include/eda_text.h +++ b/include/eda_text.h @@ -117,6 +117,8 @@ public: virtual void SetText( const wxString& aText ); + wxString EvaluateText( const wxString& aText ) const; + /** * The TextThickness is that set by the user. The EffectiveTextPenWidth also factors * in bold text and thickness clamping. diff --git a/include/text_eval/text_eval_parser.h b/include/text_eval/text_eval_parser.h new file mode 100644 index 0000000000..7780344b22 --- /dev/null +++ b/include/text_eval/text_eval_parser.h @@ -0,0 +1,454 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace calc_parser +{ + using Value = std::variant; + + // Simple token type for parser compatibility + struct TOKEN_TYPE + { + char text[256]; // Fixed size buffer for strings + double dValue; // Numeric value + bool isString; // Flag to indicate if this is a string token + }; + + // Helper functions for TOKEN_TYPE + inline TOKEN_TYPE MakeStringToken(const std::string& str) + { + TOKEN_TYPE token; + token.dValue = 0.0; + token.isString = true; + strncpy(token.text, str.c_str(), sizeof(token.text) - 1); + token.text[sizeof(token.text) - 1] = '\0'; + return token; + } + + inline TOKEN_TYPE MakeNumberToken(double val) + { + TOKEN_TYPE token; + token.dValue = val; + token.isString = false; + token.text[0] = '\0'; + return token; + } + + inline std::string GetTokenString(const TOKEN_TYPE& token) + { + return std::string(token.text); + } + + inline double GetTokenDouble(const TOKEN_TYPE& token) + { + return token.dValue; + } + + // Value utilities for type handling + class VALUE_UTILS + { + public: + // Convert Value to double (for arithmetic operations) + static auto ToDouble( const Value& aVal ) -> Result + { + if( std::holds_alternative( aVal ) ) + return MakeValue( std::get( aVal ) ); + + const auto& str = std::get( aVal ); + try + { + double value; + auto result = fast_float::from_chars( str.data(), str.data() + str.size(), value ); + + if( result.ec != std::errc() || result.ptr != str.data() + str.size() ) + throw std::invalid_argument( "Invalid number format" ); + + return MakeValue( value ); + } + catch( ... ) + { + return MakeError( std::format( "Cannot convert '{}' to number", str ) ); + } + } + + // Convert Value to string (for display/concatenation) + static auto ToString( const Value& aVal ) -> std::string + { + if( std::holds_alternative( aVal ) ) + return std::get( aVal ); + + const auto num = std::get( aVal ); + + // Smart number formatting with tolerance for floating-point precision + constexpr double tolerance = 1e-10; + double rounded = std::round( num ); + + // If the number is very close to a whole number, treat it as such + if( std::abs( num - rounded ) < tolerance && std::abs( rounded ) < 1e15 ) + return std::format( "{:.0f}", rounded ); + + return std::format( "{}", num ); + } + + // Check if Value represents a "truthy" value for conditionals + static auto IsTruthy( const Value& aVal ) -> bool + { + if( std::holds_alternative( aVal ) ) + return std::get( aVal ) != 0.0; + + return !std::get( aVal ).empty(); + } + + // arithmetic operation with type coercion + static auto ArithmeticOp( const Value& aLeft, const Value& aRight, char aOp ) -> Result + { + auto leftNum = ToDouble( aLeft ); + auto rightNum = ToDouble( aRight ); + + if( !leftNum ) return MakeError( leftNum.GetError() ); + if( !rightNum ) return MakeError( rightNum.GetError() ); + + const auto leftVal = leftNum.GetValue(); + const auto rightVal = rightNum.GetValue(); + + switch( aOp ) + { + case '+': return MakeValue( leftVal + rightVal ); + case '-': return MakeValue( leftVal - rightVal ); + case '*': return MakeValue( leftVal * rightVal ); + case '/': + if( rightVal == 0.0 ) + return MakeError( "Division by zero" ); + return MakeValue( leftVal / rightVal ); + case '%': + if( rightVal == 0.0 ) + return MakeError( "Modulo by zero" ); + return MakeValue( std::fmod( leftVal, rightVal ) ); + case '^': return MakeValue( std::pow( leftVal, rightVal ) ); + case '<': return MakeValue( leftVal < rightVal ? 1.0 : 0.0 ); + case '>': return MakeValue( leftVal > rightVal ? 1.0 : 0.0 ); + case 1: return MakeValue( leftVal <= rightVal ? 1.0 : 0.0 ); // <= + case 2: return MakeValue( leftVal >= rightVal ? 1.0 : 0.0 ); // >= + case 3: return MakeValue( leftVal == rightVal ? 1.0 : 0.0 ); // == + case 4: return MakeValue( leftVal != rightVal ? 1.0 : 0.0 ); // != + default: + return MakeError( "Unknown operator" ); + } + } + + // String concatenation (special case of '+' for strings) + static auto ConcatStrings( const Value& aLeft, const Value& aRight ) -> Value + { + return Value{ ToString( aLeft ) + ToString( aRight ) }; + } + }; + + class NODE; + class DOC; + class PARSE_CONTEXT; + + // AST Node types - supporting mixed values + enum class NodeType { Text, Calc, Var, Number, String, BinOp, Function }; + + struct BIN_OP_DATA + { + std::unique_ptr left; + std::unique_ptr right; + char op; + + BIN_OP_DATA( std::unique_ptr aLeft, char aOperation, std::unique_ptr aRight ) : + left( std::move( aLeft ) ), + right( std::move( aRight ) ), + op( aOperation ) + {} + }; + + struct FUNC_DATA + { + std::string name; + std::vector> args; + + FUNC_DATA( std::string aName, std::vector> aArguments ) : + name( std::move( aName ) ), + args( std::move( aArguments ) ) + {} + }; + + class NODE + { + public: + NodeType type; + std::variant data; + + // Factory methods for type safety + static auto CreateText( std::string aText ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::Text; + node->data = std::move( aText ); + return node; + } + + static auto CreateCalc( std::unique_ptr aExpr ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::Calc; + node->data = BIN_OP_DATA( std::move( aExpr ), '=', nullptr ); + return node; + } + + static auto CreateVar( std::string aName ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::Var; + node->data = std::move( aName ); + return node; + } + + static auto CreateNumber( double aValue ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::Number; + node->data = aValue; + return node; + } + + static auto CreateString( std::string aValue ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::String; + node->data = std::move( aValue ); + return node; + } + + static auto CreateBinOp( std::unique_ptr aLeft, char aOp, std::unique_ptr aRight ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::BinOp; + node->data = BIN_OP_DATA( std::move( aLeft ), aOp, std::move( aRight ) ); + return node; + } + + static auto CreateFunction( std::string aName, std::vector> aArgs ) -> std::unique_ptr + { + auto node = std::make_unique(); + node->type = NodeType::Function; + node->data = FUNC_DATA( std::move( aName ), std::move( aArgs ) ); + return node; + } + + // Raw pointer factory methods for parser use + static auto CreateTextRaw( std::string aText ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::Text; + node->data = std::move( aText ); + return node; + } + + static auto CreateCalcRaw( NODE* aExpr ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::Calc; + node->data = BIN_OP_DATA( std::unique_ptr( aExpr ), '=', nullptr ); + return node; + } + + static auto CreateVarRaw( std::string aName ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::Var; + node->data = std::move( aName ); + return node; + } + + static auto CreateNumberRaw( double aValue ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::Number; + node->data = aValue; + return node; + } + + static auto CreateStringRaw( std::string aValue ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::String; + node->data = std::move( aValue ); + return node; + } + + static auto CreateBinOpRaw( NODE* aLeft, char aOp, NODE* aRight ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::BinOp; + node->data = BIN_OP_DATA( std::unique_ptr( aLeft ), aOp, std::unique_ptr( aRight ) ); + return node; + } + + static auto CreateFunctionRaw( std::string aName, std::vector>* aArgs ) -> NODE* + { + auto node = new NODE(); + node->type = NodeType::Function; + node->data = FUNC_DATA( std::move( aName ), std::move( *aArgs ) ); + delete aArgs; + return node; + } + + // Mixed-type evaluation + template + auto Accept( Visitor&& aVisitor ) const -> Result + { + return std::forward( aVisitor )( *this ); + } + }; + + class DOC + { + public: + std::vector> nodes; + mutable ERROR_COLLECTOR errors; + + auto AddNode( std::unique_ptr aNode ) -> void + { + nodes.emplace_back( std::move( aNode ) ); + } + + auto AddNodeRaw( NODE* aNode ) -> void + { + nodes.emplace_back( std::unique_ptr( aNode ) ); + } + + auto HasErrors() const -> bool { return errors.HasErrors(); } + auto GetErrors() const -> const std::vector& { return errors.GetErrors(); } + auto GetErrorSummary() const -> std::string { return errors.GetAllMessages(); } + + auto GetNodes() const -> const auto& { return nodes; } + auto begin() const { return nodes.begin(); } + auto end() const { return nodes.end(); } + }; + + // Global error collector for parser callbacks + extern thread_local ERROR_COLLECTOR* g_errorCollector; + + class PARSE_CONTEXT + { + public: + ERROR_COLLECTOR& errors; + + explicit PARSE_CONTEXT( ERROR_COLLECTOR& aErrorCollector ) : + errors( aErrorCollector ) + { + g_errorCollector = &aErrorCollector; + } + + ~PARSE_CONTEXT() + { + g_errorCollector = nullptr; + } + + PARSE_CONTEXT( const PARSE_CONTEXT& ) = delete; + PARSE_CONTEXT& operator=( const PARSE_CONTEXT& ) = delete; + PARSE_CONTEXT( PARSE_CONTEXT&& ) = delete; + PARSE_CONTEXT& operator=( PARSE_CONTEXT&& ) = delete; + }; + + // Enhanced evaluation visitor supporting callback-based variable resolution + class KICOMMON_API EVAL_VISITOR + { + public: + // Callback function type for variable resolution + using VariableCallback = std::function(const std::string& aVariableName)>; + + private: + VariableCallback m_variableCallback; + [[maybe_unused]] ERROR_COLLECTOR& m_errors; + mutable std::random_device m_rd; + mutable std::mt19937 m_gen; + + public: + /** + * @brief Construct evaluator with variable callback function + * @param aVariableCallback Function to call when resolving variables + * @param aErrorCollector Error collector for storing errors + */ + explicit EVAL_VISITOR( VariableCallback aVariableCallback, ERROR_COLLECTOR& aErrorCollector ); + + // Visitor methods for evaluating different node types + auto operator()( const NODE& aNode ) const -> Result; + + private: + auto evaluateFunction( const FUNC_DATA& aFunc ) const -> Result; + }; + + // Enhanced document processor supporting callback-based variable resolution + class KICOMMON_API DOC_PROCESSOR + { + public: + using VariableCallback = EVAL_VISITOR::VariableCallback; + + /** + * @brief Process document using callback for variable resolution + * @param aDoc Document to process + * @param aVariableCallback Function to resolve variables + * @return Pair of (result_string, had_errors) + */ + static auto Process( const DOC& aDoc, VariableCallback aVariableCallback ) + -> std::pair; + + /** + * @brief Process document with detailed error reporting + * @param aDoc Document to process + * @param aVariableCallback Function to resolve variables + * @return Tuple of (result_string, error_messages, had_errors) + */ + static auto ProcessWithDetails( const DOC& aDoc, VariableCallback aVariableCallback ) + -> std::tuple, bool>; + }; + + +} // namespace calc_parser + diff --git a/include/text_eval/text_eval_types.h b/include/text_eval/text_eval_types.h new file mode 100644 index 0000000000..42091be47b --- /dev/null +++ b/include/text_eval/text_eval_types.h @@ -0,0 +1,120 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +namespace calc_parser +{ + using Value = std::variant; + + template + class Result + { + private: + std::variant m_data; + + public: + Result( T aValue ) : m_data( std::move( aValue ) ) {} + Result( std::string aError ) : m_data( std::move( aError ) ) {} + + auto HasValue() const -> bool { return std::holds_alternative( m_data ); } + auto HasError() const -> bool { return std::holds_alternative( m_data ); } + + auto GetValue() const -> const T& { return std::get( m_data ); } + auto GetError() const -> const std::string& { return std::get( m_data ); } + + explicit operator bool() const { return HasValue(); } + }; + + template + auto MakeError( std::string aMsg ) -> Result + { + return Result( std::move( aMsg ) ); + } + + template + auto MakeValue( T aVal ) -> Result + { + return Result( std::move( aVal ) ); + } + + class ERROR_COLLECTOR + { + private: + std::vector m_errors; + std::vector m_warnings; + + public: + auto AddError( std::string aError ) -> void + { + m_errors.emplace_back( std::move( aError ) ); + } + + auto AddWarning( std::string aWarning ) -> void + { + m_warnings.emplace_back( std::move( aWarning ) ); + } + + auto AddSyntaxError( int aLine = -1, int aColumn = -1 ) -> void + { + if( aLine >= 0 && aColumn >= 0 ) + AddError( std::format( "Syntax error at line {}, column {}", aLine, aColumn ) ); + else + AddError( "Syntax error in calculation expression" ); + } + + auto AddParseFailure() -> void + { + AddError( "Parser failed to parse input" ); + } + + auto HasErrors() const -> bool { return !m_errors.empty(); } + auto HasWarnings() const -> bool { return !m_warnings.empty(); } + auto GetErrors() const -> const std::vector& { return m_errors; } + auto GetWarnings() const -> const std::vector& { return m_warnings; } + + auto GetAllMessages() const -> std::string + { + std::string result; + for( const auto& error : m_errors ) + result += std::format( "Error: {}\n", error ); + + for( const auto& warning : m_warnings ) + result += std::format( "Warning: {}\n", warning ); + + return result; + } + + auto Clear() -> void + { + m_errors.clear(); + m_warnings.clear(); + } + }; + + // Forward declarations for parser-related types + class DOC; + class PARSE_CONTEXT; + class DOC_PROCESSOR; +} diff --git a/include/text_eval/text_eval_units.h b/include/text_eval/text_eval_units.h new file mode 100644 index 0000000000..2c57ebb193 --- /dev/null +++ b/include/text_eval/text_eval_units.h @@ -0,0 +1,267 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace text_eval_units { + +/** + * @brief Enumeration of all supported units in the text evaluation system + * + * This enum defines all units that can be parsed and converted by the text evaluator. + * The order matters for parsing - longer unit strings should come first to ensure + * proper matching (e.g., "ps/mm" before "ps", "thou" before "th"). + */ +enum class Unit { + // Multi-character composite units (longest first for proper parsing) + PS_PER_MM, // "ps/mm" - picoseconds per millimeter + PS_PER_CM, // "ps/cm" - picoseconds per centimeter + PS_PER_IN, // "ps/in" - picoseconds per inch + + // Multi-character simple units + THOU, // "thou" - thousandths of an inch (same as mil) + DEG, // "deg" - degrees + + // Common single and double character units + MM, // "mm" - millimeters + CM, // "cm" - centimeters + IN, // "in" - inches + MIL, // "mil" - mils (thousandths of an inch) + UM, // "um" - micrometers + PS, // "ps" - picoseconds + FS, // "fs" - femtoseconds + + // Single character units + INCH_QUOTE, // "\"" - inches (using quote character) + DEGREE_SYMBOL, // "°" - degrees (using degree symbol) + + // Invalid/unknown unit + INVALID +}; + +/** + * @brief Unit registry that provides centralized unit string mapping and conversion + * + * This class uses magic_enum to provide compile-time unit string mapping and + * runtime unit parsing/conversion capabilities. All unit-related operations + * in the text evaluator should use this registry to ensure consistency. + */ +class KICOMMON_API UnitRegistry { +public: + /** + * @brief Unit information structure + */ + struct UnitInfo { + Unit unit; + std::string_view unitString; + std::string_view description; + double conversionToMM; // Conversion factor to millimeters (base unit) + }; + +private: + // Static unit information table ordered by parsing priority (longest first) + static constexpr std::array s_unitTable = {{ + // Multi-character composite units first (longest matches) + {Unit::PS_PER_MM, "ps/mm", "Picoseconds per millimeter", 1.0}, + {Unit::PS_PER_CM, "ps/cm", "Picoseconds per centimeter", 1.0}, + {Unit::PS_PER_IN, "ps/in", "Picoseconds per inch", 1.0}, + + // Multi-character simple units + {Unit::THOU, "thou", "Thousandths of an inch", 25.4 / 1000.0}, + {Unit::DEG, "deg", "Degrees", 1.0}, + + // Common units + {Unit::MM, "mm", "Millimeters", 1.0}, + {Unit::CM, "cm", "Centimeters", 10.0}, + {Unit::IN, "in", "Inches", 25.4}, + {Unit::MIL, "mil", "Mils (thousandths of an inch)", 25.4 / 1000.0}, + {Unit::UM, "um", "Micrometers", 1.0 / 1000.0}, + {Unit::PS, "ps", "Picoseconds", 1.0}, + {Unit::FS, "fs", "Femtoseconds", 1.0}, + + // Single character units (must be last for proper parsing) + {Unit::INCH_QUOTE, "\"", "Inches (quote notation)", 25.4}, + {Unit::DEGREE_SYMBOL, "°", "Degrees (symbol)", 1.0}, + + // Invalid marker + {Unit::INVALID, "", "Invalid/unknown unit", 1.0} + }}; + +public: + /** + * @brief Parse a unit string and return the corresponding Unit enum + * @param unitStr The unit string to parse + * @return The Unit enum value, or Unit::INVALID if not recognized + */ + static constexpr Unit parseUnit(std::string_view unitStr) noexcept { + if (unitStr.empty()) { + return Unit::INVALID; + } + + // Search through unit table (ordered by priority) + for (const auto& info : s_unitTable) { + if (info.unit != Unit::INVALID && info.unitString == unitStr) { + return info.unit; + } + } + + return Unit::INVALID; + } + + /** + * @brief Get the unit string for a given Unit enum + * @param unit The Unit enum value + * @return The unit string, or empty string if invalid + */ + static constexpr std::string_view getUnitString(Unit unit) noexcept { + for (const auto& info : s_unitTable) { + if (info.unit == unit) { + return info.unitString; + } + } + return ""; + } + + /** + * @brief Get all unit strings in parsing order (longest first) + * @return Vector of all supported unit strings + */ + static std::vector getAllUnitStrings() { + std::vector units; + units.reserve(s_unitTable.size() - 1); // Exclude INVALID + + for (const auto& info : s_unitTable) { + if (info.unit != Unit::INVALID && !info.unitString.empty()) { + units.emplace_back(info.unitString); + } + } + + return units; + } + + /** + * @brief Get conversion factor from one unit to another + * @param fromUnit Source unit + * @param toUnit Target unit + * @return Conversion factor, or 1.0 if conversion not supported + */ + static constexpr double getConversionFactor(Unit fromUnit, Unit toUnit) noexcept { + if (fromUnit == toUnit) { + return 1.0; + } + + // Find conversion factors for both units + double fromToMM = 1.0; + double toFromMM = 1.0; + + for (const auto& info : s_unitTable) { + if (info.unit == fromUnit) { + fromToMM = info.conversionToMM; + } else if (info.unit == toUnit) { + toFromMM = 1.0 / info.conversionToMM; + } + } + + return fromToMM * toFromMM; + } + + /** + * @brief Convert EDA_UNITS to text evaluator Unit enum + * @param edaUnits The EDA_UNITS value + * @return Corresponding Unit enum value + */ + static constexpr Unit fromEdaUnits(EDA_UNITS edaUnits) noexcept { + switch (edaUnits) { + case EDA_UNITS::MM: return Unit::MM; + case EDA_UNITS::CM: return Unit::CM; + case EDA_UNITS::MILS: return Unit::MIL; + case EDA_UNITS::INCH: return Unit::IN; + case EDA_UNITS::DEGREES: return Unit::DEG; + case EDA_UNITS::FS: return Unit::FS; + case EDA_UNITS::PS: return Unit::PS; + case EDA_UNITS::PS_PER_INCH: return Unit::PS_PER_IN; + case EDA_UNITS::PS_PER_CM: return Unit::PS_PER_CM; + case EDA_UNITS::PS_PER_MM: return Unit::PS_PER_MM; + case EDA_UNITS::UM: return Unit::UM; + default: return Unit::MM; // Default fallback + } + } + + /** + * @brief Convert a value with units to target units + * @param value The value to convert + * @param fromUnit Source unit + * @param toUnit Target unit + * @return Converted value + */ + static constexpr double convertValue(double value, Unit fromUnit, Unit toUnit) noexcept { + return value * getConversionFactor(fromUnit, toUnit); + } + + /** + * @brief Convert a value with unit string to target EDA_UNITS + * @param value The value to convert + * @param unitStr Source unit string + * @param targetUnits Target EDA_UNITS + * @return Converted value + */ + static double convertToEdaUnits(double value, std::string_view unitStr, EDA_UNITS targetUnits) { + Unit fromUnit = parseUnit(unitStr); + if (fromUnit == Unit::INVALID) { + return value; // No conversion for invalid units + } + + Unit toUnit = fromEdaUnits(targetUnits); + return convertValue(value, fromUnit, toUnit); + } + + /** + * @brief Check if a string is a valid unit + * @param unitStr The string to check + * @return True if the string represents a valid unit + */ + static constexpr bool isValidUnit(std::string_view unitStr) noexcept { + return parseUnit(unitStr) != Unit::INVALID; + } + + /** + * @brief Get unit information for debugging/display purposes + * @param unit The unit to get information for + * @return Optional UnitInfo structure, nullopt if unit is invalid + */ + static std::optional getUnitInfo(Unit unit) noexcept { + for (const auto& info : s_unitTable) { + if (info.unit == unit) { + return info; + } + } + return std::nullopt; + } +}; + +} // namespace text_eval_units diff --git a/include/text_eval/text_eval_wrapper.h b/include/text_eval/text_eval_wrapper.h new file mode 100644 index 0000000000..f22d0662a4 --- /dev/null +++ b/include/text_eval/text_eval_wrapper.h @@ -0,0 +1,529 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +// Include EDA units support +#include + +// Include the parser types +#include + +// Forward declaration for NUMERIC_EVALUATOR compatibility +class NUMERIC_EVALUATOR_COMPAT; + +/** + * @brief High-level wrapper for evaluating mathematical and string expressions in wxString format + * + * This class provides a simple interface for evaluating expressions containing @{} syntax + * within wxString objects. It supports both map-based variable lookup and flexible + * callback-based variable resolution for dynamic data access. + * + * The evaluator can work in two modes: + * 1. Static variable mode: Variables are stored internally and looked up from memory + * 2. Callback mode: Variables are resolved dynamically using a user-provided function + * + * Example usage: + * @code + * // Static variable mode + * EXPRESSION_EVALUATOR evaluator; + * evaluator.SetVariable("price", 99.99); + * evaluator.SetVariable("product", "Widget"); + * evaluator.SetVariable("qty", 3); + * + * wxString input = "Product: @{upper(${product})} - Total: @{currency(${price} * ${qty})}"; + * wxString result = evaluator.Evaluate(input); + * // Result: "Product: WIDGET - Total: $299.97" + * + * // Callback mode + * auto callback = [](const std::string& varName) -> calc_parser::Result { + * if (varName == "current_time") { + * return calc_parser::MakeValue(getCurrentTimestamp()); + * } + * return calc_parser::MakeError("Variable not found: " + varName); + * }; + * EXPRESSION_EVALUATOR callbackEvaluator(callback); + * wxString result2 = callbackEvaluator.Evaluate("Current time: @{${current_time}}"); + * @endcode + */ +class KICOMMON_API EXPRESSION_EVALUATOR +{ +public: + // Callback function type for dynamic variable resolution + using VariableCallback = std::function(const std::string& aVariableName)>; + +private: + std::unordered_map m_variables; + mutable std::unique_ptr m_lastErrors; + bool m_clearVariablesOnEvaluate; + VariableCallback m_customCallback; + bool m_useCustomCallback; + EDA_UNITS m_defaultUnits; // Default units for calculations + +public: + /** + * @brief Construct a new Expression Evaluator in static variable mode + * @param aClearVariablesOnEvaluate If true, variables are cleared after each evaluation + */ + explicit EXPRESSION_EVALUATOR( bool aClearVariablesOnEvaluate = false ); + + /** + * @brief Construct with default units support + * @param aUnits Default units for parsing and evaluating expressions + * @param aClearVariablesOnEvaluate If true, variables are cleared after each evaluation + */ + explicit EXPRESSION_EVALUATOR( EDA_UNITS aUnits, bool aClearVariablesOnEvaluate = false ); + + /** + * @brief Construct with custom variable resolver callback + * @param aVariableCallback Custom function for variable resolution + * @param aClearVariablesOnEvaluate If true, local variables are cleared after evaluation + */ + explicit EXPRESSION_EVALUATOR( VariableCallback aVariableCallback, + bool aClearVariablesOnEvaluate = false ); + + /** + * @brief Construct with units and custom variable resolver callback + * @param aUnits Default units for parsing and evaluating expressions + * @param aVariableCallback Custom function for variable resolution + * @param aClearVariablesOnEvaluate If true, local variables are cleared after evaluation + */ + explicit EXPRESSION_EVALUATOR( EDA_UNITS aUnits, VariableCallback aVariableCallback, + bool aClearVariablesOnEvaluate = false ); + + /** + * @brief Destructor + */ + ~EXPRESSION_EVALUATOR(); + + // Copy and move operations + EXPRESSION_EVALUATOR( const EXPRESSION_EVALUATOR& aOther ); + EXPRESSION_EVALUATOR& operator=( const EXPRESSION_EVALUATOR& aOther ); + EXPRESSION_EVALUATOR( EXPRESSION_EVALUATOR&& aOther ) noexcept; + EXPRESSION_EVALUATOR& operator=( EXPRESSION_EVALUATOR&& aOther ) noexcept; + + /** + * @brief Set a custom variable resolver callback + * @param aCallback Function to call for variable resolution + * + * When set, this callback takes precedence over stored variables. + * The callback receives variable names and should return Result. + * Set to nullptr or call ClearVariableCallback() to disable callback mode. + */ + void SetVariableCallback( VariableCallback aCallback ); + + /** + * @brief Clear the custom variable resolver callback + * + * After calling this, the evaluator will use stored variables only. + */ + void ClearVariableCallback(); + + /** + * @brief Check if a custom variable callback is set + * @return true if custom callback is active + */ + bool HasVariableCallback() const; + + /** + * @brief Set the default units for expressions + * @param aUnits The units to use as default (mm, mil, inch, etc.) + * + * When expressions contain numeric values with unit suffixes (e.g., "1mm", "25mil"), + * they will be converted to the default units for calculation. + */ + void SetDefaultUnits( EDA_UNITS aUnits ); + + /** + * @brief Get the current default units + * @return Current default units + */ + EDA_UNITS GetDefaultUnits() const; + + /** + * @brief Set a numeric variable for use in expressions + * @param aName Variable name (used as ${name} in expressions) + * @param aValue Numeric value + * + * This has no effect when using callback mode, unless the callback + * chooses to fall back to stored variables. + */ + void SetVariable( const wxString& aName, double aValue ); + + /** + * @brief Set a string variable for use in expressions + * @param aName Variable name (used as ${name} in expressions) + * @param aValue String value + * + * This has no effect when using callback mode, unless the callback + * chooses to fall back to stored variables. + */ + void SetVariable( const wxString& aName, const wxString& aValue ); + + /** + * @brief Set a variable using std::string (convenience overload) + * @param aName Variable name + * @param aValue String value + */ + void SetVariable( const std::string& aName, const std::string& aValue ); + + /** + * @brief Remove a variable from the evaluator + * @param aName Variable name to remove + * @return true if variable was found and removed, false otherwise + */ + bool RemoveVariable( const wxString& aName ); + + /** + * @brief Clear all stored variables + * + * This does not affect callback-based variable resolution. + */ + void ClearVariables(); + + /** + * @brief Check if a variable exists in stored variables + * @param aName Variable name to check + * @return true if variable exists in stored variables, false otherwise + * + * Note: This only checks stored variables, not callback-resolved variables. + */ + bool HasVariable( const wxString& aName ) const; + + /** + * @brief Get the current value of a stored variable + * @param aName Variable name + * @return Variable value as wxString, or empty string if not found + * + * Note: This only returns stored variables, not callback-resolved variables. + */ + wxString GetVariable( const wxString& aName ) const; + + /** + * @brief Get all stored variable names currently defined + * @return Vector of variable names + * + * Note: This only returns stored variables, not callback-available variables. + */ + std::vector GetVariableNames() const; + + /** + * @brief Set multiple variables at once from a map + * @param aVariables Map of variable names to numeric values + */ + void SetVariables( const std::unordered_map& aVariables ); + + /** + * @brief Set multiple string variables at once from a map + * @param aVariables Map of variable names to string values + */ + void SetVariables( const std::unordered_map& aVariables ); + + /** + * @brief Main evaluation function - processes input string and evaluates all @{} expressions + * @param aInput Input string potentially containing @{} expressions + * @return Fully evaluated string with all expressions replaced by their values + * + * Variables are resolved using the callback (if set) or stored variables. + */ + wxString Evaluate( const wxString& aInput ); + + /** + * @brief Evaluate with additional temporary variables (doesn't modify stored variables) + * @param aInput Input string to evaluate + * @param aTempVariables Temporary numeric variables for this evaluation only + * @return Evaluated string + * + * Temporary variables have lower priority than callback resolution but higher + * priority than stored variables. + */ + wxString Evaluate( const wxString& aInput, + const std::unordered_map& aTempVariables ); + + /** + * @brief Evaluate with mixed temporary variables + * @param aInput Input string to evaluate + * @param aTempNumericVars Temporary numeric variables + * @param aTempStringVars Temporary string variables + * @return Evaluated string + * + * Priority order: callback > temp string vars > temp numeric vars > stored variables + */ + wxString Evaluate( const wxString& aInput, + const std::unordered_map& aTempNumericVars, + const std::unordered_map& aTempStringVars ); + + /** + * @brief Check if the last evaluation had errors + * @return true if errors occurred during last evaluation + */ + bool HasErrors() const; + + /** + * @brief Get count of errors from the last evaluation + * @return Number of errors that occurred + */ + size_t GetErrorCount() const; + + /** + * @brief Get detailed error information from the last evaluation + * @return Error summary as wxString, empty if no errors + */ + wxString GetErrorSummary() const; + + /** + * @brief Get individual error messages from the last evaluation + * @return Vector of error messages + */ + std::vector GetErrors() const; + + /** + * @brief Clear any stored error information + */ + void ClearErrors(); + + /** + * @brief Enable or disable automatic variable clearing after evaluation + * @param aEnable If true, stored variables are cleared after each Evaluate() call + * + * Note: This only affects stored variables, not callback behavior. + */ + void SetClearVariablesOnEvaluate( bool aEnable ); + + /** + * @brief Check if automatic variable clearing is enabled + * @return true if variables are cleared after each evaluation + */ + bool GetClearVariablesOnEvaluate() const; + + /** + * @brief Test if an expression can be parsed without evaluating it + * @param aExpression Single expression to test (without @{} wrapper) + * @return true if expression is syntactically valid + * + * This creates a temporary evaluator to test syntax only. + */ + bool TestExpression( const wxString& aExpression ); + + /** + * @brief Count the number of @{} expressions in input string + * @param aInput Input string to analyze + * @return Number of @{} expression blocks found + */ + size_t CountExpressions( const wxString& aInput ) const; + + /** + * @brief Extract all @{} expressions from input without evaluating + * @param aInput Input string to analyze + * @return Vector of expression strings (content between @{} markers) + */ + std::vector ExtractExpressions( const wxString& aInput ) const; + +private: + /** + * @brief Convert wxString to std::string using UTF-8 encoding + * @param aWxStr wxString to convert + * @return Converted std::string + */ + std::string wxStringToStdString( const wxString& aWxStr ) const; + + /** + * @brief Convert std::string to wxString using UTF-8 encoding + * @param aStdStr std::string to convert + * @return Converted wxString + */ + wxString stdStringToWxString( const std::string& aStdStr ) const; + + /** + * @brief Create a callback function that combines all variable sources + * @param aTempNumericVars Temporary numeric variables (optional) + * @param aTempStringVars Temporary string variables (optional) + * @return Combined callback for parser + */ + VariableCallback createCombinedCallback( + const std::unordered_map* aTempNumericVars = nullptr, + const std::unordered_map* aTempStringVars = nullptr ) const; + + /** + * @brief Parse and evaluate the input string using the expression parser + * @param aInput Input string in std::string format + * @param aVariableCallback Callback function to use for variable resolution + * @return Pair of (result_string, had_errors) + */ + std::pair evaluateWithParser( + const std::string& aInput, + VariableCallback aVariableCallback ); + + /** + * @brief Parse and evaluate with partial error recovery - malformed expressions left unchanged + * @param aInput Input string in std::string format + * @param aVariableCallback Callback function to use for variable resolution + * @return Pair of (result_string, had_errors) + */ + std::pair evaluateWithPartialErrorRecovery( + const std::string& aInput, + VariableCallback aVariableCallback ); + + /** + * @brief Full parser evaluation (original behavior) - fails completely on any error + * @param aInput Input string in std::string format + * @param aVariableCallback Callback function to use for variable resolution + * @return Pair of (result_string, had_errors) + */ + std::pair evaluateWithFullParser( + const std::string& aInput, + VariableCallback aVariableCallback ); + + /** + * @brief Expand ${variable} patterns that are outside @{} expressions + * @param aInput Input string to process + * @param aTempNumericVars Temporary numeric variables + * @param aTempStringVars Temporary string variables + * @return String with ${variable} patterns outside expressions expanded + */ + wxString expandVariablesOutsideExpressions( + const wxString& aInput, + const std::unordered_map& aTempNumericVars, + const std::unordered_map& aTempStringVars ) const; +}; + +/** + * @brief NUMERIC_EVALUATOR compatible wrapper around EXPRESSION_EVALUATOR + * + * This class provides a drop-in replacement for NUMERIC_EVALUATOR that uses + * the new EXPRESSION_EVALUATOR backend. It maintains the same API to allow + * seamless migration of existing code. + * + * The key difference is that expressions are automatically wrapped in @{...} + * syntax before evaluation. + * + * Example usage: + * @code + * // Old NUMERIC_EVALUATOR code: + * NUMERIC_EVALUATOR eval(EDA_UNITS::MM); + * eval.Process("1 + 2"); + * wxString result = eval.Result(); // "3" + * + * // New compatible code: + * NUMERIC_EVALUATOR_COMPAT eval(EDA_UNITS::MM); + * eval.Process("1 + 2"); + * wxString result = eval.Result(); // "3" + * @endcode + */ +class KICOMMON_API NUMERIC_EVALUATOR_COMPAT +{ +private: + EXPRESSION_EVALUATOR m_evaluator; + wxString m_lastInput; + wxString m_lastResult; + bool m_lastValid; + +public: + /** + * @brief Constructor with default units + * @param aUnits Default units for the evaluator + */ + explicit NUMERIC_EVALUATOR_COMPAT( EDA_UNITS aUnits ); + + /** + * @brief Destructor + */ + ~NUMERIC_EVALUATOR_COMPAT(); + + /** + * @brief Clear parser state but retain variables + * + * Resets the parser state for processing a new expression. + * User-defined variables are retained. + */ + void Clear(); + + /** + * @brief Set default units for evaluation + * @param aUnits The default units to use + */ + void SetDefaultUnits( EDA_UNITS aUnits ); + + /** + * @brief Handle locale changes (for decimal separator) + * + * This is a no-op in the EXPRESSION_EVALUATOR implementation + * since it handles locale properly internally. + */ + void LocaleChanged(); + + /** + * @brief Check if the last evaluation was successful + * @return True if last Process() call was successful + */ + bool IsValid() const; + + /** + * @brief Get the result of the last evaluation + * @return Result string, or empty if invalid + */ + wxString Result() const; + + /** + * @brief Process and evaluate an expression + * @param aString Expression to evaluate + * @return True if evaluation was successful + */ + bool Process( const wxString& aString ); + + /** + * @brief Get the original input text + * @return The last input string passed to Process() + */ + wxString OriginalText() const; + + /** + * @brief Set a variable value + * @param aString Variable name + * @param aValue Variable value + */ + void SetVar( const wxString& aString, double aValue ); + + /** + * @brief Get a variable value + * @param aString Variable name + * @return Variable value, or 0.0 if not defined + */ + double GetVar( const wxString& aString ); + + /** + * @brief Remove a single variable + * @param aString Variable name to remove + */ + void RemoveVar( const wxString& aString ); + + /** + * @brief Remove all variables + */ + void ClearVar(); +}; \ No newline at end of file diff --git a/pcbnew/pcb_text.cpp b/pcbnew/pcb_text.cpp index 3373d5581e..d8f54f091a 100644 --- a/pcbnew/pcb_text.cpp +++ b/pcbnew/pcb_text.cpp @@ -168,6 +168,9 @@ wxString PCB_TEXT::GetShownText( bool aAllowExtraText, int aDepth ) const text = ExpandTextVars( text, &resolver ); } + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + return text; } diff --git a/pcbnew/pcb_textbox.cpp b/pcbnew/pcb_textbox.cpp index 20e860990b..ce93a55510 100644 --- a/pcbnew/pcb_textbox.cpp +++ b/pcbnew/pcb_textbox.cpp @@ -445,6 +445,9 @@ wxString PCB_TEXTBOX::GetShownText( bool aAllowExtraText, int aDepth ) const text = ExpandTextVars( text, &resolver ); } + if( text.Contains( wxT( "@{" ) ) ) + text = EvaluateText( text ); + KIFONT::FONT* font = GetDrawFont( nullptr ); EDA_ANGLE drawAngle = GetDrawRotation(); std::vector corners = GetCornersInSequence( drawAngle ); diff --git a/qa/tests/common/CMakeLists.txt b/qa/tests/common/CMakeLists.txt index db422161e7..c88b2c1021 100644 --- a/qa/tests/common/CMakeLists.txt +++ b/qa/tests/common/CMakeLists.txt @@ -61,6 +61,12 @@ set( QA_COMMON_SRCS test_grid_helper.cpp test_richio.cpp test_text_attributes.cpp + text_eval/test_text_eval_parser.cpp + text_eval/test_text_eval_parser_core.cpp + text_eval/test_text_eval_parser_datetime.cpp + text_eval/test_text_eval_parser_integration.cpp + text_eval/test_text_eval_numeric_compat.cpp + text_eval/test_text_eval_render.cpp test_title_block.cpp test_types.cpp test_utf8.cpp diff --git a/qa/tests/common/text_eval/README.md b/qa/tests/common/text_eval/README.md new file mode 100644 index 0000000000..ab5e3e4395 --- /dev/null +++ b/qa/tests/common/text_eval/README.md @@ -0,0 +1,115 @@ +# Text Evaluation Parser Tests + +This directory contains test suites for the KiCad text evaluation parser functionality. + +## Test Files + +### `test_text_eval_parser.cpp` +High-level integration tests using the `EXPRESSION_EVALUATOR` wrapper class. + +- **Basic Arithmetic**: Addition, subtraction, multiplication, division, modulo, power operations +- **Variable Substitution**: Testing variable storage and retrieval in expressions +- **String Operations**: String concatenation, mixed string/number operations +- **Mathematical Functions**: `abs`, `sqrt`, `pow`, `floor`, `ceil`, `round`, `min`, `max`, `sum`, `avg` +- **String Functions**: `upper`, `lower`, `concat` +- **Formatting Functions**: `format`, `fixed`, `currency` +- **Date/Time Functions**: `today`, `now`, `dateformat`, `weekdayname` +- **Conditional Functions**: `if` statements with boolean logic +- **Random Functions**: `random()` number generation +- **Error Handling**: Syntax errors, runtime errors, undefined variables +- **Complex Expressions**: Nested functions, multi-step calculations +- **Performance Testing**: Large expressions and timing validation + +### `test_text_eval_parser_core.cpp` +Low-level unit tests for the core parser components. Tests the internal API including: + +- **ValueUtils**: Type conversion, arithmetic operations, string handling +- **Node Creation**: AST node factory methods and structure validation +- **EvaluationVisitor**: Direct AST evaluation with custom variable resolvers +- **Function Evaluation**: Individual function implementations and error cases +- **DocumentProcessor**: Document parsing and processing workflows +- **Error Collection**: Error reporting and message formatting +- **TokenType Utilities**: Token creation and manipulation + +### `test_text_eval_parser_datetime.cpp` +Specialized tests for date and time functionality: + +- **Date Formatting**: Various output formats (ISO, US, EU, Chinese, Japanese, Korean, long, short) +- **Current Date/Time**: `today()` and `now()` function validation +- **Date Arithmetic**: Adding/subtracting days, date calculations +- **Edge Cases**: Leap years, month boundaries, negative dates +- **Weekday Calculations**: Day-of-week determination and cycling +- **Performance**: Date operation timing validation + +### `test_text_eval_parser_integration.cpp` +Integration tests simulating real-world KiCad usage scenarios: + +- **Real-World Scenarios**: PCB documentation, title blocks, component summaries +- **Callback Variable Resolution**: Dynamic variable lookup from external sources +- **Thread Safety**: Multi-evaluator state isolation +- **Memory Management**: Large expression handling, resource cleanup +- **Parsing Edge Cases**: Whitespace, special characters, error recovery +- **Performance Testing**: Realistic workload simulation + +## Tested Functions + +### Mathematical Functions +- `abs(x)` - Absolute value +- `sqrt(x)` - Square root (with negative input validation) +- `pow(x, y)` - Power/exponentiation +- `floor(x)` - Round down to integer +- `ceil(x)` - Round up to integer +- `round(x, [precision])` - Round to specified decimal places +- `min(...)` - Minimum of multiple values +- `max(...)` - Maximum of multiple values +- `sum(...)` - Sum of multiple values +- `avg(...)` - Average of multiple values + +### String Functions +- `upper(str)` - Convert to uppercase +- `lower(str)` - Convert to lowercase +- `concat(...)` - Concatenate multiple values +- `format(num, [decimals])` - Format number with specified precision +- `fixed(num, [decimals])` - Fixed decimal formatting +- `currency(amount, [symbol])` - Currency formatting + +### Date/Time Functions +- `today()` - Current date as days since epoch +- `now()` - Current timestamp as seconds since epoch +- `dateformat(days, [format])` - Format date string + - Formats: "ISO", "US", "EU", "Chinese", "Japanese", "Korean", "long", "short" +- `weekdayname(days)` - Get weekday name for date + +### Conditional Functions +- `if(condition, true_value, false_value)` - Conditional evaluation + +### Utility Functions +- `random()` - Random number between 0 and 1 + +## Arithmetic Operators + +- `+` - Addition (also string concatenation) +- `-` - Subtraction (also unary minus) +- `*` - Multiplication +- `/` - Division (with zero-division error handling) +- `%` - Modulo (with zero-modulo error handling) +- `^` - Exponentiation (right-associative) + +## Variable Syntax + +Variables are referenced using `${variable_name}` syntax and can be: +- Set statically using `evaluator.SetVariable()` +- Resolved dynamically using callback functions + +## Expression Syntax + +Calculations are embedded in text using `@{expression}` syntax: +- `"Result: @{2 + 3}"` → `"Result: 5"` +- `"Hello ${name}!"` → `"Hello World!"` (with variable substitution) +- `"Area: @{${width} * ${height}} mm²"` → `"Area: 100 mm²"` + +## Error Handling + +The parser collects errors for later diagnostics. However, a string +with multiple expressions may be partially evaluated. It will return an error for every +expression that was not fully evaluated. \ No newline at end of file diff --git a/qa/tests/common/text_eval/test_text_eval_numeric_compat.cpp b/qa/tests/common/text_eval/test_text_eval_numeric_compat.cpp new file mode 100644 index 0000000000..e1fb13b179 --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_numeric_compat.cpp @@ -0,0 +1,697 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file test_text_eval_numeric_compat.cpp + * Test suite for text_eval system using examples adapted from numeric_evaluator tests + */ + +#include + +// Code under test +#include + +// Make EDA_UNITS printable for Boost.Test +std::ostream& operator<<( std::ostream& aStream, EDA_UNITS aUnits ) +{ + wxString unitStr = EDA_UNIT_UTILS::GetText( aUnits ); + return aStream << unitStr.ToStdString(); +} + +/** + * Declare the test suite + */ +BOOST_AUTO_TEST_SUITE( TextEvalNumericCompat ) + +/** + * Struct representing a test case adapted from numeric evaluator + */ +struct TEXT_EVAL_CASE +{ + wxString input; // Input expression wrapped in @{} for text_eval + wxString exp_result; // Expected result as string + bool shouldError; // Whether this case should produce an error +}; + +/** + * Basic functionality test + */ +BOOST_AUTO_TEST_CASE( Basic ) +{ + EXPRESSION_EVALUATOR evaluator; + + wxString result = evaluator.Evaluate("@{1}"); + BOOST_CHECK_EQUAL( result, "1" ); + BOOST_CHECK( !evaluator.HasErrors() ); +} + +/** + * Variable setting and usage test + */ +BOOST_AUTO_TEST_CASE( SetVar ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Set variable and test usage + evaluator.SetVariable( "MoL", 42.0 ); + wxString result = evaluator.Evaluate( "@{1 + ${MoL}}" ); + BOOST_CHECK_EQUAL( result, "43" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Change variable value + evaluator.SetVariable( "MoL", 422.0 ); + result = evaluator.Evaluate( "@{1 + ${MoL}}" ); + BOOST_CHECK_EQUAL( result, "423" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Add another variable + evaluator.SetVariable( "pi", 3.14 ); + BOOST_CHECK( evaluator.HasVariable( "pi" ) ); + + // Remove one variable + BOOST_CHECK( evaluator.RemoveVariable( "pi" ) ); + BOOST_CHECK( !evaluator.HasVariable( "pi" ) ); + + // Other variable should still be there + BOOST_CHECK( evaluator.HasVariable( "MoL" ) ); + + // Add another variable back + evaluator.SetVariable( "piish", 3.1 ); + + // Test multiple variables + result = evaluator.Evaluate( "@{1 + ${MoL} + ${piish}}" ); + BOOST_CHECK_EQUAL( result, "426.1" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Clear all variables + evaluator.ClearVariables(); + BOOST_CHECK( !evaluator.HasVariable( "MoL" ) ); + BOOST_CHECK( !evaluator.HasVariable( "piish" ) ); +} + +/** + * A list of valid test cases adapted from numeric evaluator + * All expressions are wrapped in @{} to use the text_eval system + */ +static const std::vector eval_cases_valid = { + // Empty case - text_eval handles this differently than numeric evaluator + { "@{}", "@{}", true }, // Empty expressions should error in text_eval + + // Trivial eval + { "@{1}", "1", false }, + + // Decimal separators (text_eval may handle differently) + { "@{1.5}", "1.5", false }, + // Note: comma as decimal separator might not work in text_eval + + // Simple arithmetic + { "@{1+2}", "3", false }, + { "@{1 + 2}", "3", false }, + { "@{1.5 + 0.2 + 0.1}", "1.8", false }, + { "@{3 - 10}", "-7", false }, + { "@{1 + 2 + 10 + 1000.05}", "1013.05", false }, + + // Operator precedence + { "@{1 + 2 - 4 * 20 / 2}", "-37", false }, + + // Parentheses + { "@{(1)}", "1", false }, + { "@{-(1 + (2 - 4)) * 20.8 / 2}", "10.4", false }, + + // Unary operators + { "@{+2 - 1}", "1", false }, +}; + +/** + * A list of invalid test cases adapted from numeric evaluator + */ +static const std::vector eval_cases_invalid = { + // Trailing operator + { "@{1+}", "", true }, + + // Leading operator (except unary) + { "@{*2 + 1}", "", true }, + + // Division by zero + { "@{1 / 0}", "", true }, + + // Unknown variables should preserve the original expression + { "@{1 + ${unknown}}", "@{1 + ${unknown}}", true }, + + // Mismatched parentheses + { "@{(1 + 2}", "", true }, + { "@{1 + 2)}", "", true }, + + // Invalid syntax + { "@{1 $ 2}", "", true }, +}; + +/** + * Run through valid test cases + */ +BOOST_AUTO_TEST_CASE( ValidResults ) +{ + EXPRESSION_EVALUATOR evaluator; + + for( const auto& testCase : eval_cases_valid ) + { + BOOST_TEST_CONTEXT( testCase.input + " -> " + testCase.exp_result ) + { + wxString result = evaluator.Evaluate( testCase.input ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.exp_result ); + } + } + } +} + +/** + * Run through invalid test cases + */ +BOOST_AUTO_TEST_CASE( InvalidResults ) +{ + EXPRESSION_EVALUATOR evaluator; + + for( const auto& testCase : eval_cases_invalid ) + { + BOOST_TEST_CONTEXT( testCase.input ) + { + wxString result = evaluator.Evaluate( testCase.input ); + + // All these cases should produce errors + BOOST_CHECK( evaluator.HasErrors() ); + + // For undefined variables, result should be the original expression + if( testCase.input.Contains( "${unknown}" ) ) + { + BOOST_CHECK_EQUAL( result, testCase.input ); + } + } + } +} + +/** + * Test variable usage with more complex expressions + */ +BOOST_AUTO_TEST_CASE( VariableExpressions ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Set up variables similar to numeric evaluator tests + evaluator.SetVariable( "x", 10.0 ); + evaluator.SetVariable( "y", 5.0 ); + + struct VarTestCase { + wxString input; + wxString expected; + bool shouldError; + }; + + const std::vector varCases = { + { "@{${x}}", "10", false }, + { "@{${y}}", "5", false }, + { "@{${x} + ${y}}", "15", false }, + { "@{${x} * ${y}}", "50", false }, + { "@{${x} - ${y}}", "5", false }, + { "@{${x} / ${y}}", "2", false }, + { "@{(${x} + ${y}) * 2}", "30", false }, + + // Undefined variable should preserve expression + { "@{${undefined}}", "@{${undefined}}", true }, + + // Mixed defined and undefined + { "@{${x} + ${undefined}}", "@{${x} + ${undefined}}", true }, + }; + + for( const auto& testCase : varCases ) + { + BOOST_TEST_CONTEXT( testCase.input + " -> " + testCase.expected ) + { + wxString result = evaluator.Evaluate( testCase.input ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.input ); // Original expression preserved + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } + } +} + +/** + * Test mathematical functions available in text_eval + */ +BOOST_AUTO_TEST_CASE( MathFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct MathTestCase { + wxString input; + wxString expected; + bool shouldError; + }; + + const std::vector mathCases = { + // Basic math functions that are confirmed to work + { "@{abs(-5)}", "5", false }, + { "@{min(3, 7)}", "3", false }, + { "@{max(3, 7)}", "7", false }, + { "@{sqrt(16)}", "4", false }, + { "@{ceil(3.2)}", "4", false }, + { "@{floor(3.8)}", "3", false }, + { "@{round(3.6)}", "4", false }, + { "@{pow(2, 3)}", "8", false }, + + // Sum and average functions + { "@{sum(1, 2, 3)}", "6", false }, + { "@{avg(2, 4, 6)}", "4", false }, + }; + + for( const auto& testCase : mathCases ) + { + BOOST_TEST_CONTEXT( testCase.input + " -> " + testCase.expected ) + { + wxString result = evaluator.Evaluate( testCase.input ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } + } +} + +/** + * Test unit support functionality + */ +BOOST_AUTO_TEST_CASE( UnitSupport ) +{ + // Test basic unit constructor + EXPRESSION_EVALUATOR evaluator_mm( EDA_UNITS::MM ); + EXPRESSION_EVALUATOR evaluator_inch( EDA_UNITS::INCH ); + EXPRESSION_EVALUATOR evaluator_mil( EDA_UNITS::MILS ); + + // Test unit setting and getting + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::MM ); + BOOST_CHECK_EQUAL( evaluator_inch.GetDefaultUnits(), EDA_UNITS::INCH ); + BOOST_CHECK_EQUAL( evaluator_mil.GetDefaultUnits(), EDA_UNITS::MILS ); + + // Test unit change + evaluator_mm.SetDefaultUnits( EDA_UNITS::INCH ); + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::INCH ); + + // Test basic expressions work with unit-aware evaluator + wxString result = evaluator_mm.Evaluate( "@{1 + 2}" ); + BOOST_CHECK_EQUAL( result, "3" ); + BOOST_CHECK( !evaluator_mm.HasErrors() ); + + // Test unit constructor with variable callback + auto callback = [](const std::string& varName) -> calc_parser::Result { + if (varName == "width") { + return calc_parser::MakeValue(10.0); + } + return calc_parser::MakeError("Variable not found: " + varName); + }; + + EXPRESSION_EVALUATOR evaluator_callback( EDA_UNITS::MM, callback, false ); + BOOST_CHECK_EQUAL( evaluator_callback.GetDefaultUnits(), EDA_UNITS::MM ); + BOOST_CHECK( evaluator_callback.HasVariableCallback() ); + + result = evaluator_callback.Evaluate( "@{${width} * 2}" ); + BOOST_CHECK_EQUAL( result, "20" ); + BOOST_CHECK( !evaluator_callback.HasErrors() ); +} + +/** + * Test unit conversion infrastructure readiness + * Tests the unit support foundation without exposing internal functions + */ +BOOST_AUTO_TEST_CASE( UnitInfrastructureReadiness ) +{ + // Test that different unit types can be set and retrieved + EXPRESSION_EVALUATOR evaluator_mm( EDA_UNITS::MM ); + EXPRESSION_EVALUATOR evaluator_inch( EDA_UNITS::INCH ); + EXPRESSION_EVALUATOR evaluator_mil( EDA_UNITS::MILS ); + EXPRESSION_EVALUATOR evaluator_cm( EDA_UNITS::CM ); + EXPRESSION_EVALUATOR evaluator_um( EDA_UNITS::UM ); + + // Verify unit storage works for all supported units + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::MM ); + BOOST_CHECK_EQUAL( evaluator_inch.GetDefaultUnits(), EDA_UNITS::INCH ); + BOOST_CHECK_EQUAL( evaluator_mil.GetDefaultUnits(), EDA_UNITS::MILS ); + BOOST_CHECK_EQUAL( evaluator_cm.GetDefaultUnits(), EDA_UNITS::CM ); + BOOST_CHECK_EQUAL( evaluator_um.GetDefaultUnits(), EDA_UNITS::UM ); + + // Test unit changes + evaluator_mm.SetDefaultUnits( EDA_UNITS::INCH ); + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::INCH ); + + evaluator_inch.SetDefaultUnits( EDA_UNITS::MILS ); + BOOST_CHECK_EQUAL( evaluator_inch.GetDefaultUnits(), EDA_UNITS::MILS ); + + // Verify expressions still work with all unit types + wxString result; + + result = evaluator_mm.Evaluate( "@{5 * 2}" ); + BOOST_CHECK_EQUAL( result, "10" ); + + result = evaluator_inch.Evaluate( "@{3.5 + 1.5}" ); + BOOST_CHECK_EQUAL( result, "5" ); + + result = evaluator_mil.Evaluate( "@{100 / 4}" ); + BOOST_CHECK_EQUAL( result, "25" ); + + // Test complex expressions work with unit-aware evaluators + result = evaluator_cm.Evaluate( "@{(10 + 5) * 2 - 1}" ); + BOOST_CHECK_EQUAL( result, "29" ); + + // Test variable support with units + evaluator_um.SetVariable( "length", 25.4 ); + result = evaluator_um.Evaluate( "@{${length} * 2}" ); + BOOST_CHECK_EQUAL( result, "50.8" ); + + // Test that unit-aware evaluator preserves its unit setting across operations + EXPRESSION_EVALUATOR persistent_eval( EDA_UNITS::MILS ); + BOOST_CHECK_EQUAL( persistent_eval.GetDefaultUnits(), EDA_UNITS::MILS ); + + persistent_eval.Evaluate( "@{1 + 1}" ); + BOOST_CHECK_EQUAL( persistent_eval.GetDefaultUnits(), EDA_UNITS::MILS ); + + persistent_eval.SetVariable( "test", 42 ); + BOOST_CHECK_EQUAL( persistent_eval.GetDefaultUnits(), EDA_UNITS::MILS ); + + persistent_eval.Evaluate( "@{${test} + 8}" ); + BOOST_CHECK_EQUAL( persistent_eval.GetDefaultUnits(), EDA_UNITS::MILS ); + BOOST_CHECK_EQUAL( persistent_eval.Evaluate( "@{${test} + 8}" ), "50" ); +} + +/** + * Test mixed unit arithmetic expectations using known conversion factors + * Documents expected behavior for when unit parsing is integrated + */ +BOOST_AUTO_TEST_CASE( UnitMixingExpectations ) +{ + EXPRESSION_EVALUATOR evaluator_mm( EDA_UNITS::MM ); + + // Verify basic functionality works before discussing unit mixing + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::MM ); + + wxString result = evaluator_mm.Evaluate( "@{2 + 3}" ); + BOOST_CHECK_EQUAL( result, "5" ); + + // Test complex arithmetic + result = evaluator_mm.Evaluate( "@{(1 + 2) * 3}" ); + BOOST_CHECK_EQUAL( result, "9" ); + + // Test with decimals (important for unit conversions) + result = evaluator_mm.Evaluate( "@{25.4 + 12.7}" ); + // Use close comparison for floating point + double numeric_result = wxAtof( result ); + BOOST_CHECK_CLOSE( numeric_result, 38.1, 0.01 ); + + // Test with variables that could represent converted values + evaluator_mm.SetVariable( "inch_in_mm", 25.4 ); // 1 inch = 25.4 mm + evaluator_mm.SetVariable( "mil_in_mm", 0.0254 ); // 1 mil = 0.0254 mm + + result = evaluator_mm.Evaluate( "@{${inch_in_mm} + ${mil_in_mm}}" ); + BOOST_CHECK_EQUAL( result, "25.4254" ); + + // Simulate what "1mm + 1in" should become when units are parsed + evaluator_mm.SetVariable( "mm_part", 1.0 ); + evaluator_mm.SetVariable( "in_part", 25.4 ); // 1in converted to mm + result = evaluator_mm.Evaluate( "@{${mm_part} + ${in_part}}" ); + BOOST_CHECK_EQUAL( result, "26.4" ); + + // Simulate what "1in + 1000mil" should become + evaluator_mm.SetVariable( "one_inch", 25.4 ); + evaluator_mm.SetVariable( "thousand_mils", 25.4 ); // 1000 mils = 1 inch = 25.4 mm + result = evaluator_mm.Evaluate( "@{${one_inch} + ${thousand_mils}}" ); + BOOST_CHECK_EQUAL( result, "50.8" ); + + // Test expressions that will be possible once unit parsing is integrated: + // These would parse "1mm", "1in", "1mil" etc. and convert to default units + + + // Basic unit expressions + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1mm}" ), "1" ); + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1in}" ), "25.4" ); + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1000mil}" ), "25.4" ); + + // Mixed unit arithmetic + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1mm + 1in}" ), "26.4" ); + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1in + 1000mil}" ), "50.8" ); + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{10mm + 0.5in + 500mil}" ), "35.4" ); + + // Unit expressions with whitespace + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1 mm}" ), "1" ); + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{1 in}" ), "25.4" ); + + // Complex mixed unit expressions with variables + evaluator_mm.SetVariable( "width", 10 ); // 10mm + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{${width}mm + 1in}" ), "35.4" ); + // These two should both work the same + BOOST_CHECK_EQUAL( evaluator_mm.Evaluate( "@{${width} * 1mm + 1in}" ), "35.4" ); + + // Different evaluator units should convert appropriately + EXPRESSION_EVALUATOR evaluator_inch( EDA_UNITS::INCH ); + BOOST_CHECK_EQUAL( evaluator_inch.Evaluate( "@{1in}" ), "1" ); + BOOST_CHECK_EQUAL( evaluator_inch.Evaluate( "@{25.4mm}" ), "1" ); +} + +/** + * Test actual unit parsing integration (now that unit parsing is implemented) + */ +BOOST_AUTO_TEST_CASE( ActualUnitParsing ) +{ + // Test MM evaluator with unit expressions + EXPRESSION_EVALUATOR evaluator_mm( EDA_UNITS::MM ); + BOOST_CHECK_EQUAL( evaluator_mm.GetDefaultUnits(), EDA_UNITS::MM ); + + // Test basic unit expressions (these should now work!) + wxString result; + + // Debug: Test basic arithmetic first + result = evaluator_mm.Evaluate( "@{2+3}" ); + BOOST_CHECK_EQUAL( result, "5" ); + + // Debug: Test just number + result = evaluator_mm.Evaluate( "@{1}" ); + BOOST_CHECK_EQUAL( result, "1" ); + + // Debug: Test the working case + result = evaluator_mm.Evaluate( "@{1in}" ); + if (result != "25.4") { + std::cout << "DEBUG: @{1in} returned '" << result.ToStdString() << "'" << std::endl; + if (evaluator_mm.HasErrors()) { + std::cout << "DEBUG: @{1in} Errors: " << evaluator_mm.GetErrorSummary().ToStdString() << std::endl; + } + } + BOOST_CHECK_EQUAL( result, "25.4" ); + + result = evaluator_mm.Evaluate( "@{1mil}" ); + BOOST_CHECK_EQUAL( result, "0.0254" ); + + result = evaluator_mm.Evaluate( "@{1mm}" ); + BOOST_CHECK_EQUAL( result, "1" ); + + // 1 inch should convert to 25.4 mm + result = evaluator_mm.Evaluate( "@{1in}" ); + BOOST_CHECK_EQUAL( result, "25.4" ); + + // 1000 mils should convert to 25.4 mm (1000 mils = 1 inch) + result = evaluator_mm.Evaluate( "@{1000mil}" ); + BOOST_CHECK_EQUAL( result, "25.4" ); + + // Test mixed unit arithmetic + result = evaluator_mm.Evaluate( "@{1mm + 1in}" ); + BOOST_CHECK_EQUAL( result, "26.4" ); + + result = evaluator_mm.Evaluate( "@{1in + 1000mil}" ); + BOOST_CHECK_EQUAL( result, "50.8" ); + + // Test more complex expressions + result = evaluator_mm.Evaluate( "@{10mm + 0.5in + 500mil}" ); + BOOST_CHECK_EQUAL( result, "35.4" ); + + // Test unit expressions with spaces (if supported) + result = evaluator_mm.Evaluate( "@{1 mm}" ); + BOOST_CHECK_EQUAL( result, "1" ); + + // Test with different default units + EXPRESSION_EVALUATOR evaluator_inch( EDA_UNITS::INCH ); + + // 1 inch should be 1 when default unit is inches + result = evaluator_inch.Evaluate( "@{1in}" ); + BOOST_CHECK_EQUAL( result, "1" ); + + // 25.4mm should convert to 1 inch (with floating point tolerance) + result = evaluator_inch.Evaluate( "@{25.4mm}" ); + // Use approximate comparison for floating point + double result_val = wxAtof(result); + BOOST_CHECK( std::abs(result_val - 1.0) < 0.001 ); + + // Test arithmetic with inch evaluator + result = evaluator_inch.Evaluate( "@{1in + 1000mil}" ); + BOOST_CHECK_EQUAL( result, "2" ); // 1 inch + 1 inch = 2 inches + + // Test centimeters + result = evaluator_mm.Evaluate( "@{1cm}" ); + BOOST_CHECK_EQUAL( result, "10" ); // 1 cm = 10 mm + + // Test micrometers + result = evaluator_mm.Evaluate( "@{1000um}" ); + BOOST_CHECK_EQUAL( result, "1" ); // 1000 um = 1 mm + + // Test quotes for inches + result = evaluator_mm.Evaluate( "@{1\"}" ); + BOOST_CHECK_EQUAL( result, "25.4" ); // 1" = 25.4 mm + + // Test complex mixed expressions with parentheses + result = evaluator_mm.Evaluate( "@{(1in + 500mil) * 2}" ); + // Expected: (25.4 + 12.7) * 2 = 38.1 * 2 = 76.2mm + double result_val2 = wxAtof(result); + BOOST_CHECK( std::abs(result_val2 - 76.2) < 0.001 ); +} + +/** + * Test unit parsing edge cases and error handling + */ +BOOST_AUTO_TEST_CASE( UnitParsingEdgeCases, * boost::unit_test::enabled() ) +{ + EXPRESSION_EVALUATOR evaluator_mm( EDA_UNITS::MM ); + + // Test invalid units (should be treated as plain numbers) + wxString result = evaluator_mm.Evaluate( "@{1xyz}" ); + // Should parse as "1" followed by identifier "xyz", might be an error or treat as 1 + // This behavior depends on implementation details + + // Test numbers without units (should work normally) + result = evaluator_mm.Evaluate( "@{25.4}" ); + BOOST_CHECK_EQUAL( result, "25.4" ); + + // Test zero with units + result = evaluator_mm.Evaluate( "@{0mm}" ); + BOOST_CHECK_EQUAL( result, "0" ); + + result = evaluator_mm.Evaluate( "@{0in}" ); + BOOST_CHECK_EQUAL( result, "0" ); + + // Test decimal values with units + result = evaluator_mm.Evaluate( "@{2.54cm}" ); + BOOST_CHECK_EQUAL( result, "25.4" ); // 2.54 cm = 25.4 mm + + result = evaluator_mm.Evaluate( "@{0.5in}" ); + BOOST_CHECK_EQUAL( result, "12.7" ); // 0.5 inch = 12.7 mm + + // Test very small values + result = evaluator_mm.Evaluate( "@{1um}" ); + BOOST_CHECK_EQUAL( result, "0.001" ); // 1 um = 0.001 mm +} + +BOOST_AUTO_TEST_CASE( NumericEvaluatorCompatibility ) +{ + // Test the NUMERIC_EVALUATOR_COMPAT wrapper class that provides + // a drop-in replacement for NUMERIC_EVALUATOR using EXPRESSION_EVALUATOR backend + + NUMERIC_EVALUATOR_COMPAT eval( EDA_UNITS::MM ); + + // Test basic arithmetic + BOOST_CHECK( eval.Process( "1 + 2" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "3" ); + BOOST_CHECK_EQUAL( eval.OriginalText(), "1 + 2" ); + + // Test variables + eval.SetVar( "x", 5.0 ); + eval.SetVar( "y", 3.0 ); + + BOOST_CHECK( eval.Process( "x + y" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "8" ); + + // Test GetVar + BOOST_CHECK_CLOSE( eval.GetVar( "x" ), 5.0, 0.001 ); + BOOST_CHECK_CLOSE( eval.GetVar( "y" ), 3.0, 0.001 ); + BOOST_CHECK_CLOSE( eval.GetVar( "undefined" ), 0.0, 0.001 ); + + // Test units (should work seamlessly) + BOOST_CHECK( eval.Process( "1in + 1mm" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "26.4" ); // 1 inch + 1mm in mm + + // Test mathematical functions + BOOST_CHECK( eval.Process( "sqrt(16)" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "4" ); + + // Test invalid expression - use something clearly invalid + BOOST_CHECK( !eval.Process( "1 + * 2" ) ); // Clearly invalid: two operators in a row + BOOST_CHECK( !eval.IsValid() ); + + // Test Clear() - should reset state but keep variables + eval.Clear(); + BOOST_CHECK_CLOSE( eval.GetVar( "x" ), 5.0, 0.001 ); // Variables should still be there + + BOOST_CHECK( eval.Process( "x * 2" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "10" ); + + // Test variable removal + eval.RemoveVar( "x" ); + BOOST_CHECK_CLOSE( eval.GetVar( "x" ), 0.0, 0.001 ); // Should be 0.0 for undefined + BOOST_CHECK_CLOSE( eval.GetVar( "y" ), 3.0, 0.001 ); // y should still exist + + // Test ClearVar() + eval.ClearVar(); + BOOST_CHECK_CLOSE( eval.GetVar( "y" ), 0.0, 0.001 ); // All variables should be gone + + // Test that we can still use the evaluator after clearing + BOOST_CHECK( eval.Process( "42" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "42" ); + + // Test LocaleChanged() - should be a no-op but not crash + eval.LocaleChanged(); + BOOST_CHECK( eval.Process( "3.14" ) ); + BOOST_CHECK( eval.IsValid() ); + BOOST_CHECK_EQUAL( eval.Result(), "3.14" ); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/qa/tests/common/text_eval/test_text_eval_parser.cpp b/qa/tests/common/text_eval/test_text_eval_parser.cpp new file mode 100644 index 0000000000..ad46cf402a --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_parser.cpp @@ -0,0 +1,589 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Test suite for text_eval_parser routines + */ + +#include + +// Code under test +#include + +#include +#include +#include + +/** + * Declare the test suite + */ +BOOST_AUTO_TEST_SUITE( TextEvalParser ) + +/** + * Test basic arithmetic operations + */ +BOOST_AUTO_TEST_CASE( BasicArithmetic ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Basic operations + { "Text @{2 + 3} more text", "Text 5 more text", false }, + { "@{10 - 4}", "6", false }, + { "@{7 * 8}", "56", false }, + { "@{15 / 3}", "5", false }, + { "@{17 % 5}", "2", false }, + { "@{2^3}", "8", false }, + + // Order of operations + { "@{2 + 3 * 4}", "14", false }, + { "@{(2 + 3) * 4}", "20", false }, + { "@{2^3^2}", "512", false }, // Right associative + { "@{-5}", "-5", false }, + { "@{+5}", "5", false }, + + // Floating point + { "@{3.14 + 1.86}", "5", false }, + { "@{10.5 / 2}", "5.25", false }, + { "@{3.5 * 2}", "7", false }, + + // Edge cases + { "@{1 / 0}", "Text @{1 / 0} more text", true }, // Division by zero + { "@{1 % 0}", "Text @{1 % 0} more text", true }, // Modulo by zero + + // Multiple calculations in one string + { "@{2 + 2} and @{3 * 3}", "4 and 9", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test variable substitution + */ +BOOST_AUTO_TEST_CASE( VariableSubstitution ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Set up some variables + evaluator.SetVariable( "x", 10.0 ); + evaluator.SetVariable( "y", 5.0 ); + evaluator.SetVariable( wxString("name"), wxString("KiCad") ); + evaluator.SetVariable( "version", 8.0 ); + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Basic variable substitution + { "@{${x}}", "10", false }, + { "@{${y}}", "5", false }, + { "Hello ${name}!", "Hello KiCad!", false }, + + // Variables in calculations + { "@{${x} + ${y}}", "15", false }, + { "@{${x} * ${y}}", "50", false }, + { "@{${x} - ${y}}", "5", false }, + { "@{${x} / ${y}}", "2", false }, + + // Mixed text and variable calculations + { "Product: @{${x} * ${y}} units", "Product: 50 units", false }, + { "Version ${version}.0", "Version 8.0", false }, + + // Undefined variables + { "@{${undefined}}", "@{${undefined}}", true }, + + // String variables + { "Welcome to ${name}", "Welcome to KiCad", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test string operations and concatenation + */ +BOOST_AUTO_TEST_CASE( StringOperations ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( wxString("prefix"), wxString("Hello") ); + evaluator.SetVariable( wxString("suffix"), wxString("World") ); + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // String concatenation with + + { "@{\"Hello\" + \" \" + \"World\"}", "Hello World", false }, + { "@{${prefix} + \" \" + ${suffix}}", "Hello World", false }, + + // Mixed string and number concatenation + { "@{\"Count: \" + 42}", "Count: 42", false }, + { "@{42 + \" items\"}", "42 items", false }, + + // String literals + { "@{\"Simple string\"}", "Simple string", false }, + { "Prefix @{\"middle\"} suffix", "Prefix middle suffix", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } +} + +/** + * Test mathematical functions + */ +BOOST_AUTO_TEST_CASE( MathematicalFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + double tolerance; + }; + + const std::vector cases = { + // Basic math functions + { "@{abs(-5)}", "5", false, 0.001 }, + { "@{abs(3.14)}", "3.14", false, 0.001 }, + { "@{sqrt(16)}", "4", false, 0.001 }, + { "@{sqrt(2)}", "1.414", false, 0.01 }, + { "@{pow(2, 3)}", "8", false, 0.001 }, + { "@{pow(3, 2)}", "9", false, 0.001 }, + + // Rounding functions + { "@{floor(3.7)}", "3", false, 0.001 }, + { "@{ceil(3.2)}", "4", false, 0.001 }, + { "@{round(3.7)}", "4", false, 0.001 }, + { "@{round(3.2)}", "3", false, 0.001 }, + { "@{round(3.14159, 2)}", "3.14", false, 0.001 }, + + // Min/Max functions + { "@{min(5, 3, 8, 1)}", "1", false, 0.001 }, + { "@{max(5, 3, 8, 1)}", "8", false, 0.001 }, + { "@{min(3.5, 3.1)}", "3.1", false, 0.001 }, + + // Sum and average + { "@{sum(1, 2, 3, 4)}", "10", false, 0.001 }, + { "@{avg(2, 4, 6)}", "4", false, 0.001 }, + + // Error cases + { "@{sqrt(-1)}", "Text @{sqrt(-1)} more text", true, 0 }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + + if( testCase.tolerance > 0 ) + { + // For floating point comparisons + double actualValue = wxStrtod( result, nullptr ); + double expectedValue = wxStrtod( testCase.expected, nullptr ); + BOOST_CHECK_CLOSE( actualValue, expectedValue, testCase.tolerance * 100 ); + } + else + { + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } + } +} + +/** + * Test string manipulation functions + */ +BOOST_AUTO_TEST_CASE( StringFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( wxString("text"), wxString("Hello World") ); + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Case conversion + { "@{upper(\"hello world\")}", "HELLO WORLD", false }, + { "@{lower(\"HELLO WORLD\")}", "hello world", false }, + { "@{upper(${text})}", "HELLO WORLD", false }, + + // String concatenation function + { "@{concat(\"Hello\", \" \", \"World\")}", "Hello World", false }, + { "@{concat(\"Count: \", 42, \" items\")}", "Count: 42 items", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } +} + +/** + * Test formatting functions + */ +BOOST_AUTO_TEST_CASE( FormattingFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Number formatting + { "@{format(3.14159)}", "3.14", false }, + { "@{format(3.14159, 3)}", "3.142", false }, + { "@{format(1234.5)}", "1234.50", false }, + { "@{fixed(3.14159, 2)}", "3.14", false }, + + // Currency formatting + { "@{currency(1234.56)}", "$1234.56", false }, + { "@{currency(999.99, \"€\")}", "€999.99", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } +} + +/** + * Test date and time functions + */ +BOOST_AUTO_TEST_CASE( DateTimeFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Note: These tests will be time-sensitive. We test the functions exist + // and return reasonable values rather than exact matches. + + struct TestCase { + std::string expression; + bool shouldContainNumbers; + bool shouldError; + }; + + const std::vector cases = { + // Date functions that return numbers (days since epoch) + { "@{today()}", true, false }, + { "@{now()}", true, false }, // Returns timestamp + + // Date formatting (these return specific dates so we can test exactly) + { "@{dateformat(0)}", false, false }, // Should format epoch date + { "@{dateformat(0, \"ISO\")}", false, false }, + { "@{dateformat(0, \"US\")}", false, false }, + { "@{weekdayname(0)}", false, false }, // Should return weekday name + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK( !result.empty() ); + + if( testCase.shouldContainNumbers ) + { + // Result should be a number + BOOST_CHECK( std::all_of( result.begin(), result.end(), + []( char c ) { return std::isdigit( c ) || c == '.' || c == '-'; } ) ); + } + } + } + + // Test specific date formatting with known values + auto result1 = evaluator.Evaluate( "@{dateformat(0, \"ISO\")}" ); + BOOST_CHECK_EQUAL( result1, "1970-01-01" ); // Unix epoch + + auto result2 = evaluator.Evaluate( "@{weekdayname(0)}" ); + BOOST_CHECK_EQUAL( result2, "Thursday" ); // Unix epoch was a Thursday +} + +/** + * Test conditional functions + */ +BOOST_AUTO_TEST_CASE( ConditionalFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( "x", 10.0 ); + evaluator.SetVariable( "y", 5.0 ); + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Basic if function + { "@{if(1, \"true\", \"false\")}", "true", false }, + { "@{if(0, \"true\", \"false\")}", "false", false }, + { "@{if(${x} > ${y}, \"greater\", \"not greater\")}", "greater", false }, + { "@{if(${x} < ${y}, \"less\", \"not less\")}", "not less", false }, + + // Numeric if results + { "@{if(1, 42, 24)}", "42", false }, + { "@{if(0, 42, 24)}", "24", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } +} + +/** + * Test random functions + */ +BOOST_AUTO_TEST_CASE( RandomFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Test that random function returns a value between 0 and 1 + auto result = evaluator.Evaluate( "@{random()}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + double randomValue = wxStrtod( result, nullptr ); + BOOST_CHECK_GE( randomValue, 0.0 ); + BOOST_CHECK_LT( randomValue, 1.0 ); + + // Test that consecutive calls return different values (with high probability) + auto result2 = evaluator.Evaluate( "@{random()}" ); + double randomValue2 = wxStrtod( result2, nullptr ); + + // It's theoretically possible these could be equal, but extremely unlikely + BOOST_CHECK_NE( randomValue, randomValue2 ); +} + +/** + * Test error handling and edge cases + */ +BOOST_AUTO_TEST_CASE( ErrorHandling ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + bool shouldError; + std::string description; + }; + + const std::vector cases = { + // Syntax errors + { "@{2 +}", true, "incomplete expression" }, + { "@{(2 + 3", true, "unmatched parenthesis" }, + { "@{2 + 3)}", true, "extra closing parenthesis" }, + { "@{}", true, "empty calculation" }, + + // Unknown functions + { "@{unknownfunc(1, 2)}", true, "unknown function" }, + + // Wrong number of arguments + { "@{abs()}", true, "abs with no arguments" }, + { "@{abs(1, 2)}", true, "abs with too many arguments" }, + { "@{sqrt()}", true, "sqrt with no arguments" }, + + // Runtime errors + { "@{1 / 0}", true, "division by zero" }, + { "@{sqrt(-1)}", true, "square root of negative" }, + + // Valid expressions that should not error + { "Plain text", false, "plain text should not error" }, + { "@{2 + 2}", false, "simple calculation should work" }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK_MESSAGE( evaluator.HasErrors(), + "Expected error for: " + testCase.description ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Unexpected error for: " + testCase.description ); + } + } +} + +/** + * Test complex nested expressions + */ +BOOST_AUTO_TEST_CASE( ComplexExpressions ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( "pi", 3.14159 ); + evaluator.SetVariable( "radius", 5.0 ); + + struct TestCase { + std::string expression; + std::string expected; + double tolerance; + }; + + const std::vector cases = { + // Complex mathematical expressions + { "@{2 * ${pi} * ${radius}}", "31.42", 0.01 }, // Circumference + { "@{${pi} * pow(${radius}, 2)}", "78.54", 0.01 }, // Area + { "@{sqrt(pow(3, 2) + pow(4, 2))}", "5", 0.001 }, // Pythagorean theorem + + // Nested function calls + { "@{max(abs(-5), sqrt(16), floor(3.7))}", "5", 0.001 }, + { "@{round(avg(1.1, 2.2, 3.3), 1)}", "2.2", 0.001 }, + + // Mixed string and math + { "Circle with radius @{${radius}} has area @{format(${pi} * pow(${radius}, 2), 1)}", + "Circle with radius 5 has area 78.5", 0 }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + std::string resultStr = result.ToStdString(); + BOOST_CHECK( !evaluator.HasErrors() ); + + if( testCase.tolerance > 0 ) + { + // Extract numeric part for comparison + std::regex numberRegex( R"([\d.]+)" ); + std::smatch match; + + if( std::regex_search( resultStr, match, numberRegex ) ) + { + double actualValue = std::stod( match[0].str() ); + double expectedValue = std::stod( testCase.expected ); + BOOST_CHECK_CLOSE( actualValue, expectedValue, testCase.tolerance * 100 ); + } + } + else + { + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test performance with large expressions + */ +BOOST_AUTO_TEST_CASE( Performance ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Build a large expression with many calculations + std::string largeExpression = "Result: "; + for( int i = 0; i < 50; ++i ) + { + largeExpression += "@{" + std::to_string(i) + " * 2} "; + } + + auto start = std::chrono::high_resolution_clock::now(); + auto result = evaluator.Evaluate( largeExpression ); + auto end = std::chrono::high_resolution_clock::now(); + + auto duration = std::chrono::duration_cast( end - start ); + + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK( !result.empty() ); + + // Should complete in reasonable time (less than 1 second) + BOOST_CHECK_LT( duration.count(), 1000 ); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/qa/tests/common/text_eval/test_text_eval_parser_core.cpp b/qa/tests/common/text_eval/test_text_eval_parser_core.cpp new file mode 100644 index 0000000000..3c5ee0ebb2 --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_parser_core.cpp @@ -0,0 +1,508 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Test suite for low-level text_eval_parser functionality + */ + +#include + +// Code under test +#include + +#include +#include + +using namespace calc_parser; + +/** + * Declare the test suite + */ +BOOST_AUTO_TEST_SUITE( TextEvalParserLowLevel ) + +/** + * Helper function to create a simple variable resolver for testing + */ +auto CreateTestVariableResolver() +{ + auto variables = std::make_shared>(); + + // Set up some test variables + (*variables)["x"] = 10.0; + (*variables)["y"] = 5.0; + (*variables)["name"] = std::string("KiCad"); + (*variables)["pi"] = 3.14159; + + return [variables]( const std::string& varName ) -> Result + { + auto it = variables->find( varName ); + if( it != variables->end() ) + return MakeValue( it->second ); + + return MakeError( "Variable not found: " + varName ); + }; +} + +/** + * Test VALUE_UTILS functionality + */ +BOOST_AUTO_TEST_CASE( ValueUtils ) +{ + // Test ToDouble conversion + { + Value numVal = 42.5; + auto result = calc_parser::VALUE_UTILS::ToDouble( numVal ); + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( result.GetValue(), 42.5, 0.001 ); + } + + { + Value strVal = std::string("123.45"); + auto result = calc_parser::VALUE_UTILS::ToDouble( strVal ); + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( result.GetValue(), 123.45, 0.001 ); + } + + { + Value invalidStr = std::string("not_a_number"); + auto result = calc_parser::VALUE_UTILS::ToDouble( invalidStr ); + BOOST_CHECK( result.HasError() ); + } + + // Test ToString conversion + { + Value numVal = 42.0; + auto result = calc_parser::VALUE_UTILS::ToString( numVal ); + BOOST_CHECK_EQUAL( result, "42" ); + } + + { + Value strVal = std::string("Hello"); + auto result = calc_parser::VALUE_UTILS::ToString( strVal ); + BOOST_CHECK_EQUAL( result, "Hello" ); + } + + // Test IsTruthy + { + BOOST_CHECK( calc_parser::VALUE_UTILS::IsTruthy( Value{1.0} ) ); + BOOST_CHECK( !calc_parser::VALUE_UTILS::IsTruthy( Value{0.0} ) ); + BOOST_CHECK( calc_parser::VALUE_UTILS::IsTruthy( Value{std::string("non-empty")} ) ); + BOOST_CHECK( !calc_parser::VALUE_UTILS::IsTruthy( Value{std::string("")} ) ); + } + + // Test ArithmeticOp + { + Value left = 10.0; + Value right = 3.0; + + auto addResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '+' ); + BOOST_CHECK( addResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( addResult.GetValue() ), 13.0, 0.001 ); + + auto subResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '-' ); + BOOST_CHECK( subResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( subResult.GetValue() ), 7.0, 0.001 ); + + auto mulResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '*' ); + BOOST_CHECK( mulResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( mulResult.GetValue() ), 30.0, 0.001 ); + + auto divResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '/' ); + BOOST_CHECK( divResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( divResult.GetValue() ), 3.333, 0.1 ); + + auto modResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '%' ); + BOOST_CHECK( modResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( modResult.GetValue() ), 1.0, 0.001 ); + + auto powResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '^' ); + BOOST_CHECK( powResult.HasValue() ); + BOOST_CHECK_CLOSE( std::get( powResult.GetValue() ), 1000.0, 0.001 ); + } + + // Test division by zero + { + Value left = 10.0; + Value right = 0.0; + + auto divResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '/' ); + BOOST_CHECK( divResult.HasError() ); + + auto modResult = calc_parser::VALUE_UTILS::ArithmeticOp( left, right, '%' ); + BOOST_CHECK( modResult.HasError() ); + } + + // Test ConcatStrings + { + Value left = std::string("Hello "); + Value right = std::string("World"); + auto result = calc_parser::VALUE_UTILS::ConcatStrings( left, right ); + BOOST_CHECK_EQUAL( std::get( result ), "Hello World" ); + } + + { + Value left = 42.0; + Value right = std::string(" items"); + auto result = calc_parser::VALUE_UTILS::ConcatStrings( left, right ); + BOOST_CHECK_EQUAL( std::get( result ), "42 items" ); + } +} + +/** + * Test Node creation and basic structure + */ +BOOST_AUTO_TEST_CASE( NodeCreation ) +{ + // Test number node + { + auto node = NODE::CreateNumber( 42.5 ); + BOOST_CHECK( node->type == NodeType::Number ); + BOOST_CHECK_CLOSE( std::get( node->data ), 42.5, 0.001 ); + } + + // Test string node + { + auto node = NODE::CreateString( "Hello World" ); + BOOST_CHECK( node->type == NodeType::String ); + BOOST_CHECK_EQUAL( std::get( node->data ), "Hello World" ); + } + + // Test variable node + { + auto node = NODE::CreateVar( "testVar" ); + BOOST_CHECK( node->type == NodeType::Var ); + BOOST_CHECK_EQUAL( std::get( node->data ), "testVar" ); + } + + // Test binary operation node + { + auto left = NODE::CreateNumber( 10.0 ); + auto right = NODE::CreateNumber( 5.0 ); + auto binOp = NODE::CreateBinOp( std::move( left ), '+', std::move( right ) ); + + BOOST_CHECK( binOp->type == NodeType::BinOp ); + const auto& binOpData = std::get( binOp->data ); + BOOST_CHECK( binOpData.op == '+' ); + BOOST_CHECK( binOpData.left != nullptr ); + BOOST_CHECK( binOpData.right != nullptr ); + } + + // Test function node + { + std::vector> args; + args.push_back( NODE::CreateNumber( 5.0 ) ); + + auto funcNode = NODE::CreateFunction( "abs", std::move( args ) ); + BOOST_CHECK( funcNode->type == NodeType::Function ); + + const auto& funcData = std::get( funcNode->data ); + BOOST_CHECK_EQUAL( funcData.name, "abs" ); + BOOST_CHECK_EQUAL( funcData.args.size(), 1 ); + } +} + +/** + * Test evaluation visitor with simple expressions + */ +BOOST_AUTO_TEST_CASE( EvaluationVisitor ) +{ + ERROR_COLLECTOR errors; + auto varResolver = CreateTestVariableResolver(); + calc_parser::EVAL_VISITOR evaluator( varResolver, errors ); + + // Test number evaluation + { + auto node = NODE::CreateNumber( 42.5 ); + auto result = node->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 42.5, 0.001 ); + } + + // Test string evaluation + { + auto node = NODE::CreateString( "Hello" ); + auto result = node->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_EQUAL( std::get( result.GetValue() ), "Hello" ); + } + + // Test variable evaluation + { + auto node = NODE::CreateVar( "x" ); + auto result = node->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 10.0, 0.001 ); + } + + // Test undefined variable + { + auto node = NODE::CreateVar( "undefined" ); + auto result = node->Accept( evaluator ); + + BOOST_CHECK( result.HasError() ); + } + + // Test binary operation + { + auto left = NODE::CreateNumber( 10.0 ); + auto right = NODE::CreateNumber( 5.0 ); + auto binOp = NODE::CreateBinOp( std::move( left ), '+', std::move( right ) ); + + auto result = binOp->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 15.0, 0.001 ); + } + + // Test string concatenation with + + { + auto left = NODE::CreateString( "Hello " ); + auto right = NODE::CreateString( "World" ); + auto binOp = NODE::CreateBinOp( std::move( left ), '+', std::move( right ) ); + + auto result = binOp->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_EQUAL( std::get( result.GetValue() ), "Hello World" ); + } +} + +/** + * Test function evaluation + */ +BOOST_AUTO_TEST_CASE( FunctionEvaluation ) +{ + ERROR_COLLECTOR errors; + auto varResolver = CreateTestVariableResolver(); + calc_parser::EVAL_VISITOR evaluator( varResolver, errors ); + + // Test abs function + { + std::vector> args; + args.push_back( NODE::CreateNumber( -5.0 ) ); + + auto funcNode = NODE::CreateFunction( "abs", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 5.0, 0.001 ); + } + + // Test sqrt function + { + std::vector> args; + args.push_back( NODE::CreateNumber( 16.0 ) ); + + auto funcNode = NODE::CreateFunction( "sqrt", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 4.0, 0.001 ); + } + + // Test sqrt with negative number (should error) + { + std::vector> args; + args.push_back( NODE::CreateNumber( -1.0 ) ); + + auto funcNode = NODE::CreateFunction( "sqrt", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasError() ); + } + + // Test max function + { + std::vector> args; + args.push_back( NODE::CreateNumber( 3.0 ) ); + args.push_back( NODE::CreateNumber( 7.0 ) ); + args.push_back( NODE::CreateNumber( 1.0 ) ); + + auto funcNode = NODE::CreateFunction( "max", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK_CLOSE( std::get( result.GetValue() ), 7.0, 0.001 ); + } + + // Test string function - upper + { + std::vector> args; + args.push_back( NODE::CreateString( "hello" ) ); + + auto funcNode = NODE::CreateFunction( "upper", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_EQUAL( std::get( result.GetValue() ), "HELLO" ); + } + + // Test format function + { + std::vector> args; + args.push_back( NODE::CreateNumber( 3.14159 ) ); + args.push_back( NODE::CreateNumber( 2.0 ) ); + + auto funcNode = NODE::CreateFunction( "format", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + BOOST_CHECK_EQUAL( std::get( result.GetValue() ), "3.14" ); + } + + // Test unknown function + { + std::vector> args; + args.push_back( NODE::CreateNumber( 1.0 ) ); + + auto funcNode = NODE::CreateFunction( "unknownfunc", std::move( args ) ); + auto result = funcNode->Accept( evaluator ); + + BOOST_CHECK( result.HasError() ); + } + + // Test zero-argument functions + { + std::vector> args; // Empty args + + auto todayNode = NODE::CreateFunction( "today", std::move( args ) ); + auto result = todayNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + // Should return a reasonable number of days since epoch + auto days = std::get( result.GetValue() ); + BOOST_CHECK_GT( days, 18000 ); // Should be after year 2019 + } + + { + std::vector> args; // Empty args + + auto randomNode = NODE::CreateFunction( "random", std::move( args ) ); + auto result = randomNode->Accept( evaluator ); + + BOOST_CHECK( result.HasValue() ); + BOOST_CHECK( std::holds_alternative( result.GetValue() ) ); + auto randomVal = std::get( result.GetValue() ); + BOOST_CHECK_GE( randomVal, 0.0 ); + BOOST_CHECK_LT( randomVal, 1.0 ); + } +} + +/** + * Test DOC_PROCESSOR functionality + */ +BOOST_AUTO_TEST_CASE( DocumentProcessor ) +{ + auto varResolver = CreateTestVariableResolver(); + + // Create a simple document with text and calculations + auto doc = std::make_unique(); + + // Add text node + doc->AddNode( NODE::CreateText( "Value is " ) ); + + // Add calculation node + auto calcExpr = NODE::CreateBinOp( + NODE::CreateNumber( 2.0 ), + '+', + NODE::CreateNumber( 3.0 ) + ); + doc->AddNode( NODE::CreateCalc( std::move( calcExpr ) ) ); + + // Add more text + doc->AddNode( NODE::CreateText( " units" ) ); + + // Process the document + auto [result, hadErrors] = calc_parser::DOC_PROCESSOR::Process( *doc, varResolver ); + + BOOST_CHECK( !hadErrors ); + BOOST_CHECK_EQUAL( result, "Value is 5 units" ); +} + +/** + * Test error collection and reporting + */ +BOOST_AUTO_TEST_CASE( ErrorHandling ) +{ + ERROR_COLLECTOR errors; + + // Test adding errors + errors.AddError( "Test error 1" ); + errors.AddWarning( "Test warning 1" ); + errors.AddError( "Test error 2" ); + + BOOST_CHECK( errors.HasErrors() ); + BOOST_CHECK( errors.HasWarnings() ); + + const auto& errorList = errors.GetErrors(); + BOOST_CHECK_EQUAL( errorList.size(), 2 ); + BOOST_CHECK_EQUAL( errorList[0], "Test error 1" ); + BOOST_CHECK_EQUAL( errorList[1], "Test error 2" ); + + const auto& warningList = errors.GetWarnings(); + BOOST_CHECK_EQUAL( warningList.size(), 1 ); + BOOST_CHECK_EQUAL( warningList[0], "Test warning 1" ); + + // Test error message formatting + auto allMessages = errors.GetAllMessages(); + BOOST_CHECK( allMessages.find( "Error: Test error 1" ) != std::string::npos ); + BOOST_CHECK( allMessages.find( "Warning: Test warning 1" ) != std::string::npos ); + + // Test clearing + errors.Clear(); + BOOST_CHECK( !errors.HasErrors() ); + BOOST_CHECK( !errors.HasWarnings() ); +} + +/** + * Test TOKEN_TYPE utilities + */ +BOOST_AUTO_TEST_CASE( TokenTypes ) +{ + // Test string token + { + auto token = MakeStringToken( "Hello World" ); + BOOST_CHECK( token.isString ); + BOOST_CHECK_EQUAL( GetTokenString( token ), "Hello World" ); + } + + // Test number token + { + auto token = MakeNumberToken( 42.5 ); + BOOST_CHECK( !token.isString ); + BOOST_CHECK_CLOSE( GetTokenDouble( token ), 42.5, 0.001 ); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/qa/tests/common/text_eval/test_text_eval_parser_datetime.cpp b/qa/tests/common/text_eval/test_text_eval_parser_datetime.cpp new file mode 100644 index 0000000000..e981ce900f --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_parser_datetime.cpp @@ -0,0 +1,471 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free softwar { "@{datestring('2023年12月25日')}", "19716", false }, // Christmas 2023 { "@{datestring('2023年12月25日')}", "19716", false }, // Christmas 2023 { "@{datestring('2023年12월25일')}", "19716", false }, // Christmas 2023; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Test suite for text_eval_parser date and time functionality + */ + +#include +#include + +#include +#include + +BOOST_AUTO_TEST_SUITE( TextEvalParserDateTime ) + +BOOST_AUTO_TEST_CASE( DateFormatting ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Test Unix epoch date (1970-01-01) + { "@{dateformat(0)}", "1970-01-01", false }, + { "@{dateformat(0, \"ISO\")}", "1970-01-01", false }, + { "@{dateformat(0, \"iso\")}", "1970-01-01", false }, + { "@{dateformat(0, \"US\")}", "01/01/1970", false }, + { "@{dateformat(0, \"us\")}", "01/01/1970", false }, + { "@{dateformat(0, \"EU\")}", "01/01/1970", false }, + { "@{dateformat(0, \"european\")}", "01/01/1970", false }, + { "@{dateformat(0, \"long\")}", "January 1, 1970", false }, + { "@{dateformat(0, \"short\")}", "Jan 1, 1970", false }, + + // Test some known dates + { "@{dateformat(365)}", "1971-01-01", false }, // One year after epoch + { "@{dateformat(1000)}", "1972-09-27", false }, // 1000 days after epoch + + // Test weekday names + { "@{weekdayname(0)}", "Thursday", false }, // Unix epoch was Thursday + { "@{weekdayname(1)}", "Friday", false }, // Next day + { "@{weekdayname(2)}", "Saturday", false }, // Weekend + { "@{weekdayname(3)}", "Sunday", false }, + { "@{weekdayname(4)}", "Monday", false }, + { "@{weekdayname(5)}", "Tuesday", false }, + { "@{weekdayname(6)}", "Wednesday", false }, + { "@{weekdayname(7)}", "Thursday", false }, // Week cycles + + // Test negative dates (before epoch) + { "@{dateformat(-1)}", "1969-12-31", false }, + { "@{weekdayname(-1)}", "Wednesday", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Error in expression: " + testCase.expression + + " Errors: " + evaluator.GetErrorSummary() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test CJK (Chinese, Japanese, Korean) date formatting + */ +BOOST_AUTO_TEST_CASE( CJKDateFormatting ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Test Unix epoch date (1970-01-01) in CJK formats + { "@{dateformat(0, \"Chinese\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"chinese\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"CN\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"cn\")}", "1970年01月01日", false }, + + { "@{dateformat(0, \"Japanese\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"japanese\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"JP\")}", "1970年01月01日", false }, + { "@{dateformat(0, \"jp\")}", "1970年01月01日", false }, + + { "@{dateformat(0, \"Korean\")}", "1970년 01월 01일", false }, + { "@{dateformat(0, \"korean\")}", "1970년 01월 01일", false }, + { "@{dateformat(0, \"KR\")}", "1970년 01월 01일", false }, + { "@{dateformat(0, \"kr\")}", "1970년 01월 01일", false }, + + // Test some other dates in CJK formats + { "@{dateformat(365, \"Chinese\")}", "1971年01月01日", false }, // One year after epoch + { "@{dateformat(365, \"Japanese\")}", "1971年01月01日", false }, // One year after epoch + { "@{dateformat(365, \"Korean\")}", "1971년 01월 01일", false }, // One year after epoch + + { "@{dateformat(1000, \"Chinese\")}", "1972年09月27日", false }, // 1000 days after epoch + { "@{dateformat(1000, \"Japanese\")}", "1972年09月27日", false }, // 1000 days after epoch + { "@{dateformat(1000, \"Korean\")}", "1972년 09월 27일", false }, // 1000 days after epoch + + // Test negative dates (before epoch) in CJK formats + { "@{dateformat(-1, \"Chinese\")}", "1969年12月31日", false }, + { "@{dateformat(-1, \"Japanese\")}", "1969年12月31日", false }, + { "@{dateformat(-1, \"Korean\")}", "1969년 12월 31일", false }, + + // Test leap year date (Feb 29, 1972) in CJK formats + { "@{dateformat(789, \"Chinese\")}", "1972年02月29日", false }, // Feb 29, 1972 (leap year) + { "@{dateformat(789, \"Japanese\")}", "1972年02月29日", false }, // Feb 29, 1972 (leap year) + { "@{dateformat(789, \"Korean\")}", "1972년 02월 29일", false }, // Feb 29, 1972 (leap year) + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Error in expression: " + testCase.expression + + " Errors: " + evaluator.GetErrorSummary() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test CJK (Chinese, Japanese, Korean) date parsing with datestring function + */ +BOOST_AUTO_TEST_CASE( CJKDateParsing ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Test if basic functions work first + { "@{dateformat(0)}", "1970-01-01", false }, // Test basic dateformat + { "@{upper(\"test\")}", "TEST", false }, // Test basic string function + + // Test ASCII date parsing first to see if datestring function works + { "@{datestring(\"2024-03-15\")}", "19797", false }, // Test ASCII date + { "@{datestring(\"1970-01-01\")}", "0", false }, // Unix epoch + + // Test Chinese date parsing (年月日) + { "@{datestring('2024年03月15日')}", "19797", false }, // Days since epoch for 2024-03-15 + { "@{datestring('1970年01月01日')}", "0", false }, // Unix epoch + { "@{datestring('2024年01月01日')}", "19723", false }, // New Year 2024 + { "@{datestring('1972年02月29日')}", "789", false }, // Leap year date + { "@{datestring('1969年12月31日')}", "-1", false }, // Day before epoch + + // Test Korean date parsing (년월일) with spaces + { "@{datestring(\"2024년 03월 15일\")}", "19797", false }, // Days since epoch for 2024-03-15 + { "@{datestring(\"1970년 01월 01일\")}", "0", false }, // Unix epoch + { "@{datestring(\"2024년 01월 01일\")}", "19723", false }, // New Year 2024 + { "@{datestring(\"1972년 02월 29일\")}", "789", false }, // Leap year date + { "@{datestring(\"1969년 12월 31일\")}", "-1", false }, // Day before epoch + + // Test Korean date parsing (년월일) without spaces + { "@{datestring(\"2024년03월15일\")}", "19797", false }, // Days since epoch for 2024-03-15 + { "@{datestring(\"1970년01월01일\")}", "0", false }, // Unix epoch + + // Test integration: parse CJK date and format in different style + { "@{dateformat(datestring('2024年03월15일'), 'ISO')}", "2024-03-15", false }, + { "@{dateformat(datestring('2024년 03월 15일'), 'ISO')}", "2024-03-15", false }, + { "@{dateformat(datestring('1970年01月01日'), 'US')}", "01/01/1970", false }, + { "@{dateformat(datestring('1970년 01월 01일'), 'EU')}", "01/01/1970", false }, + + // Test round-trip: CJK -> parse -> format back to CJK + { "@{dateformat(datestring('2024年03월15日'), 'Chinese')}", "2024年03月15日", false }, + { "@{dateformat(datestring('2024년 03월 15일'), 'Korean')}", "2024년 03월 15일", false }, + + // Test invalid CJK dates (should error) + { "@{datestring('2024年13月15日')}", "", true }, // Invalid month + { "@{datestring('2024년 02월 30일')}", "", true }, // Invalid day for February + { "@{datestring('2024年02月')}", "", true }, // Missing day + { "@{datestring('2024년')}", "", true }, // Missing month and day + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK_MESSAGE( evaluator.HasErrors(), + "Expected error but got result: " + result + + " for expression: " + testCase.expression ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Error in expression: " + testCase.expression + + " Errors: " + evaluator.GetErrorSummary() ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test current date/time functions + */ +BOOST_AUTO_TEST_CASE( CurrentDateTime ) +{ + EXPRESSION_EVALUATOR evaluator; + + auto todayResult = evaluator.Evaluate( "@{today()}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Should return a number (days since epoch) + double todayDays = std::stod( todayResult.ToStdString() ); + + // Should be a reasonable number of days since 1970 + // As of 2024, this should be over 19,000 days + BOOST_CHECK_GT( todayDays, 19000 ); + BOOST_CHECK_LT( todayDays, 50000 ); // Reasonable upper bound + + // Test now() function + auto nowResult = evaluator.Evaluate( "@{now()}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Should return a timestamp (seconds since epoch) + double nowTimestamp = std::stod( nowResult.ToStdString() ); + + // Should be a reasonable timestamp + auto currentTime = std::chrono::system_clock::now(); + auto currentTimestamp = std::chrono::system_clock::to_time_t( currentTime ); + double currentTimestampDouble = static_cast( currentTimestamp ); + + // Should be within a few seconds of current time + BOOST_CHECK_CLOSE( nowTimestamp, currentTimestampDouble, 1.0 ); // Within 1% + + // Test that consecutive calls to today() return the same value + auto todayResult2 = evaluator.Evaluate( "@{today()}" ); + BOOST_CHECK_EQUAL( todayResult, todayResult2 ); + + // Test formatting current date + auto formattedToday = evaluator.Evaluate( "@{dateformat(today(), \"ISO\")}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Should be in ISO format: YYYY-MM-DD + std::regex isoDateRegex( R"(\d{4}-\d{2}-\d{2})" ); + BOOST_CHECK( std::regex_match( formattedToday.ToStdString(), isoDateRegex ) ); +} + +/** + * Test date arithmetic and calculations + */ +BOOST_AUTO_TEST_CASE( DateArithmetic ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Date arithmetic + { "@{dateformat(0 + 1)}", "1970-01-02", false }, // Add one day + { "@{dateformat(0 + 7)}", "1970-01-08", false }, // Add one week + { "@{dateformat(0 + 30)}", "1970-01-31", false }, // Add 30 days + { "@{dateformat(0 + 365)}", "1971-01-01", false }, // Add one year (1970 was not leap) + + // Leap year test + { "@{dateformat(365 + 365 + 366)}", "1973-01-01", false }, // 1972 was leap year + + // Date differences + { "@{365 - 0}", "365", false }, // Days between dates + + // Complex date expressions + { "@{weekdayname(today())}", "", false }, // Should return a weekday name (we can't predict which) + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + + if( !testCase.expected.empty() ) + { + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + else + { + // For dynamic results like weekday names, just check it's not empty + BOOST_CHECK( !result.empty() ); + } + } + } +} + +/** + * Test date edge cases and boundary conditions + */ +BOOST_AUTO_TEST_CASE( DateEdgeCases ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + }; + + const std::vector cases = { + // Leap year boundaries + { "@{dateformat(365 + 365 + 59)}", "1972-02-29", false }, // Feb 29, 1972 (leap year) + { "@{dateformat(365 + 365 + 60)}", "1972-03-01", false }, // Mar 1, 1972 + + // Year boundaries + { "@{dateformat(365 - 1)}", "1970-12-31", false }, // Last day of 1970 + { "@{dateformat(365)}", "1971-01-01", false }, // First day of 1971 + + // Month boundaries + { "@{dateformat(30)}", "1970-01-31", false }, // Last day of January + { "@{dateformat(31)}", "1970-02-01", false }, // First day of February + { "@{dateformat(58)}", "1970-02-28", false }, // Last day of February 1970 (not leap) + { "@{dateformat(59)}", "1970-03-01", false }, // First day of March 1970 + + // Large date values + { "@{dateformat(36525)}", "2070-01-01", false }, // 100 years after epoch + + // Negative dates (before epoch) + { "@{dateformat(-365)}", "1969-01-01", false }, // One year before epoch + { "@{dateformat(-1)}", "1969-12-31", false }, // One day before epoch + + // Weekday wrap-around + { "@{weekdayname(-1)}", "Wednesday", false }, // Day before Thursday + { "@{weekdayname(-7)}", "Thursday", false }, // One week before + + // Edge case: very large weekday values + { "@{weekdayname(7000)}", "Thursday", false }, // Should still work + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Error in expression: " + testCase.expression ); + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } +} + +/** + * Test date formatting with mixed expressions + */ +BOOST_AUTO_TEST_CASE( DateFormattingMixed ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( "days_offset", 100.0 ); + + struct TestCase { + std::string expression; + bool shouldWork; + }; + + const std::vector cases = { + // Complex expressions combining dates and variables + { "Today is @{dateformat(today())} which is @{weekdayname(today())}", true }, + { "Date: @{dateformat(0 + ${days_offset}, \"long\")}", true }, + { "In @{format(${days_offset})} days: @{dateformat(today() + ${days_offset})}", true }, + + // Nested function calls + { "@{upper(weekdayname(today()))}", true }, + { "@{lower(dateformat(today(), \"long\"))}", true }, + + // Multiple date calculations + { "Start: @{dateformat(0)} End: @{dateformat(365)} Duration: @{365 - 0} days", true }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldWork ) + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Error in expression: " + testCase.expression + + " Result: " + result ); + BOOST_CHECK( !result.empty() ); + } + else + { + BOOST_CHECK( evaluator.HasErrors() ); + } + } +} + +/** + * Test performance of date operations + */ +BOOST_AUTO_TEST_CASE( DatePerformance ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Test that date operations are reasonably fast + auto start = std::chrono::high_resolution_clock::now(); + + // Perform many date operations + for( int i = 0; i < 1000; ++i ) + { + auto result = evaluator.Evaluate( "@{dateformat(" + std::to_string(i) + ")}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast( end - start ); + + // Should complete in reasonable time (less than 100 milliseconds for 1000 operations) + BOOST_CHECK_LT( duration.count(), 100 ); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/qa/tests/common/text_eval/test_text_eval_parser_integration.cpp b/qa/tests/common/text_eval/test_text_eval_parser_integration.cpp new file mode 100644 index 0000000000..8671a00db3 --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_parser_integration.cpp @@ -0,0 +1,441 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Integration tests for text_eval_parser functionality including real-world scenarios + */ + +#include + +// Code under test +#include + +#include +#include + +/** + * Declare the test suite + */ +BOOST_AUTO_TEST_SUITE( TextEvalParserIntegration ) + +/** + * Test real-world expression scenarios + */ +BOOST_AUTO_TEST_CASE( RealWorldScenarios ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Set up variables that might be used in actual KiCad projects + evaluator.SetVariable( "board_width", 100.0 ); + evaluator.SetVariable( "board_height", 80.0 ); + evaluator.SetVariable( "trace_width", 0.2 ); + evaluator.SetVariable( "component_count", 45.0 ); + evaluator.SetVariable( "revision", 3.0 ); + evaluator.SetVariable( std::string("project_name"), std::string("My PCB Project") ); + evaluator.SetVariable( std::string("designer"), std::string("John Doe") ); + + struct TestCase { + std::string expression; + std::string expectedPattern; // Can be exact match or regex pattern + bool isRegex; + bool shouldError; + std::string description; + }; + + const std::vector cases = { + // Board dimension calculations + { + "Board area: @{${board_width} * ${board_height}} mm²", + "Board area: 8000 mm²", + false, false, + "Board area calculation" + }, + { + "Perimeter: @{2 * (${board_width} + ${board_height})} mm", + "Perimeter: 360 mm", + false, false, + "Board perimeter calculation" + }, + { + "Diagonal: @{format(sqrt(pow(${board_width}, 2) + pow(${board_height}, 2)), 1)} mm", + "Diagonal: 128.1 mm", + false, false, + "Board diagonal calculation" + }, + + // Text formatting scenarios + { + "Project: ${project_name} | Designer: ${designer} | Rev: @{${revision}}", + "Project: My PCB Project | Designer: John Doe | Rev: 3", + false, false, + "Title block information" + }, + { + "Components: @{${component_count}} | Density: @{format(${component_count} / (${board_width} * ${board_height} / 10000), 2)} per cm²", + "Components: 45 | Density: 56.25 per cm²", + false, false, + "Component density calculation" + }, + + // Date-based revision tracking + { + "Created: @{dateformat(today())} | Build: @{today()} days since epoch", + R"(Created: \d{4}-\d{2}-\d{2} \| Build: \d+ days since epoch)", + true, false, + "Date-based tracking" + }, + + // Conditional formatting + { + "Status: @{if(${component_count} > 50, \"Complex\", \"Simple\")} design", + "Status: Simple design", + false, false, + "Conditional design complexity" + }, + { + "Status: @{if(${trace_width} >= 0.2, \"Standard\", \"Fine pitch\")} (@{${trace_width}}mm)", + "Status: Standard (0.2mm)", + false, false, + "Conditional trace width description" + }, + + // Multi-line documentation + { + "PCB Summary:\n- Size: @{${board_width}}×@{${board_height}}mm\n- Area: @{${board_width} * ${board_height}}mm²\n- Components: @{${component_count}}", + "PCB Summary:\n- Size: 100×80mm\n- Area: 8000mm²\n- Components: 45", + false, false, + "Multi-line documentation" + }, + + // Error scenarios - undefined variables error and return unchanged + { + "Invalid: @{${undefined_var}} test", + "Invalid: @{${undefined_var}} test", + false, true, + "Undefined variable behavior" + }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK_MESSAGE( evaluator.HasErrors(), + "Expected error for: " + testCase.description ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Unexpected error for: " + testCase.description + + " - " + evaluator.GetErrorSummary().ToStdString() ); + + if( testCase.isRegex ) + { + std::regex pattern( testCase.expectedPattern ); + BOOST_CHECK_MESSAGE( std::regex_match( result.ToStdString(), pattern ), + "Result '" + result.ToStdString() + "' doesn't match pattern '" + + testCase.expectedPattern + "' for: " + testCase.description ); + } + else + { + BOOST_CHECK_MESSAGE( result.ToStdString() == testCase.expectedPattern, + "Expected '" + testCase.expectedPattern + "' but got '" + + result.ToStdString() + "' for: " + testCase.description ); + } + } + } +} + +/** + * Test callback-based variable resolution + */ +BOOST_AUTO_TEST_CASE( CallbackVariableResolution ) +{ + // Create evaluator with custom callback + auto variableCallback = []( const std::string& varName ) -> calc_parser::Result { + if( varName == "dynamic_value" ) + return calc_parser::MakeValue( 42.0 ); + else if( varName == "dynamic_string" ) + return calc_parser::MakeValue( std::string("Hello from callback") ); + else if( varName == "computed_value" ) + return calc_parser::MakeValue( std::sin( 3.14159 / 4 ) * 100.0 ); // Should be about 70.7 + else + return calc_parser::MakeError( "Variable '" + varName + "' not found in callback" ); + }; + + EXPRESSION_EVALUATOR evaluator( variableCallback, false ); + + struct TestCase { + std::string expression; + std::string expected; + double tolerance; + bool shouldError; + }; + + const std::vector cases = { + { "@{${dynamic_value}}", "42", 0, false }, + { "Message: ${dynamic_string}", "Message: Hello from callback", 0, false }, + { "@{format(${computed_value}, 1)}", "70.7", 0.1, false }, + { "@{${dynamic_value} + ${computed_value}}", "112.7", 0.1, false }, + { "${nonexistent}", "${nonexistent}", 0, true }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + + if( testCase.tolerance > 0 ) + { + // For floating point comparisons, extract the number + std::regex numberRegex( R"([\d.]+)" ); + std::smatch match; + std::string resultStr = result.ToStdString(); + if( std::regex_search( resultStr, match, numberRegex ) ) + { + double actualValue = std::stod( match[0].str() ); + double expectedValue = std::stod( testCase.expected ); + BOOST_CHECK_CLOSE( actualValue, expectedValue, testCase.tolerance * 100 ); + } + } + else + { + BOOST_CHECK_EQUAL( result, testCase.expected ); + } + } + } +} + +/** + * Test concurrent/thread safety (basic test) + */ +BOOST_AUTO_TEST_CASE( ThreadSafety ) +{ + // Create multiple evaluators that could be used in different threads + std::vector> evaluators; + + for( int i = 0; i < 10; ++i ) + { + auto evaluator = std::make_unique(); + evaluator->SetVariable( "thread_id", static_cast( i ) ); + evaluator->SetVariable( "multiplier", 5.0 ); + evaluators.push_back( std::move( evaluator ) ); + } + + // Test that each evaluator maintains its own state + for( int i = 0; i < 10; ++i ) + { + auto result = evaluators[i]->Evaluate( "@{${thread_id} * ${multiplier}}" ); + BOOST_CHECK( !evaluators[i]->HasErrors() ); + + double expected = static_cast( i * 5 ); + double actual = std::stod( result.ToStdString() ); + BOOST_CHECK_CLOSE( actual, expected, 0.001 ); + } +} + +/** + * Test memory management and large expressions + */ +BOOST_AUTO_TEST_CASE( MemoryManagement ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Test large nested expressions + std::string complexExpression = "@{"; + for( int i = 0; i < 100; ++i ) + { + if( i > 0 ) complexExpression += " + "; + complexExpression += std::to_string( i ); + } + complexExpression += "}"; + + auto result = evaluator.Evaluate( complexExpression ); + BOOST_CHECK( !evaluator.HasErrors() ); + + // Sum of 0..99 is 4950 + BOOST_CHECK_EQUAL( result, "4950" ); + + // Test many small expressions + for( int i = 0; i < 1000; ++i ) + { + auto expr = "@{" + std::to_string( i ) + " * 2}"; + auto result = evaluator.Evaluate( expr ); + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK_EQUAL( result, std::to_string( i * 2 ) ); + } +} + +/** + * Test edge cases in parsing and evaluation + */ +BOOST_AUTO_TEST_CASE( ParsingEdgeCases ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase { + std::string expression; + std::string expected; + bool shouldError; + double precision; + std::string description; + }; + + const std::vector cases = { + // Whitespace handling + { "@{ 2 + 3 }", "5", false, 0.0, "Spaces in expression" }, + { "@{\t2\t+\t3\t}", "5", false, 0.0, "Tabs in expression" }, + { "@{\n2\n+\n3\n}", "5", false, 0.0, "Newlines in expression" }, + + // String escaping and special characters + { "@{\"Hello\\\"World\\\"\"}", "Hello\"World\"", false, 0.0, "Escaped quotes in string" }, + { "@{\"Line1\\nLine2\"}", "Line1\nLine2", false, 0.0, "Newline in string" }, + + // Multiple calculations in complex text + { "A: @{1+1}, B: @{2*2}, C: @{3^2}", "A: 2, B: 4, C: 9", false, 0.0, "Multiple calculations" }, + + // Edge cases with parentheses + { "@{((((2))))}", "2", false, 0.0, "Multiple nested parentheses" }, + { "@{(2 + 3) * (4 + 5)}", "45", false, 0.0, "Grouped operations" }, + + // Empty and minimal expressions + { "No calculations here", "No calculations here", false, 0.0, "Plain text" }, + { "", "", false, 0.0, "Empty string" }, + { "@{0}", "0", false, 0.0, "Zero value" }, + { "@{-0}", "0", false, 0.0, "Negative zero" }, + + // Precision and rounding edge cases + { "@{0.1 + 0.2}", "0.3", false, 0.01, "Floating point precision" }, + { "@{1.0 / 3.0}", "0.333333", false, 0.01, "Repeating decimal" }, + + // Large numbers + { "@{1000000 * 1000000}", "1e+12", false, 0.01, "Large number result" }, + + // Error recovery - malformed expressions left unchanged, valid ones evaluated + { "Good @{2+2} bad @{2+} good @{3+3}", "Good 4 bad @{2+} good 6", true, 0.0, "Error recovery" }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK_MESSAGE( evaluator.HasErrors(), "Expected error for: " + testCase.description ); + } + else + { + if( testCase.precision > 0.0 ) + { + // For floating point comparisons, extract the number + std::regex numberRegex( R"([\d.eE+-]+)" ); + std::smatch match; + std::string resultStr = result.ToStdString(); + if( std::regex_search( resultStr, match, numberRegex ) ) + { + double actualValue = std::stod( match[0].str() ); + double expectedValue = std::stod( testCase.expected ); + BOOST_CHECK_CLOSE( actualValue, expectedValue, testCase.precision * 100 ); + } + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), + "Unexpected error for: " + testCase.description + + " - " + evaluator.GetErrorSummary() ); + BOOST_CHECK_MESSAGE( result == testCase.expected, + "Expected '" + testCase.expected + "' but got '" + + result + "' for: " + testCase.description ); + } + } + } +} + +/** + * Test performance with realistic workloads + */ +BOOST_AUTO_TEST_CASE( RealWorldPerformance ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Set up variables for a typical PCB project + evaluator.SetVariable( "board_layers", 4.0 ); + evaluator.SetVariable( "component_count", 150.0 ); + evaluator.SetVariable( "net_count", 200.0 ); + evaluator.SetVariable( "via_count", 300.0 ); + evaluator.SetVariable( "board_width", 120.0 ); + evaluator.SetVariable( "board_height", 80.0 ); + + // Simulate processing many text objects (like in a real PCB layout) + std::vector expressions = { + "Layer @{${board_layers}}/4", + "Components: @{${component_count}}", + "Nets: @{${net_count}}", + "Vias: @{${via_count}}", + "Area: @{${board_width} * ${board_height}} mm²", + "Density: @{format(${component_count} / (${board_width} * ${board_height} / 100), 1)} /cm²", + "Via density: @{format(${via_count} / (${board_width} * ${board_height} / 100), 1)} /cm²", + "Layer utilization: @{format(${net_count} / ${board_layers}, 1)} nets/layer", + "Design complexity: @{if(${component_count} > 100, \"High\", \"Low\")}", + "Board aspect ratio: @{format(${board_width} / ${board_height}, 2)}:1", + }; + + auto start = std::chrono::high_resolution_clock::now(); + + // Process expressions many times (simulating real usage) + for( int iteration = 0; iteration < 100; ++iteration ) + { + for( const auto& expr : expressions ) + { + auto result = evaluator.Evaluate( expr ); + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK( !result.empty() ); + } + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast( end - start ); + + // Should process 1000 expressions in reasonable time (less than 100 ms) + BOOST_CHECK_LT( duration.count(), 100 ); + + // Test that results are consistent + for( auto& expr : expressions ) + { + auto result1 = evaluator.Evaluate( expr ); + auto result2 = evaluator.Evaluate( expr ); + BOOST_CHECK_EQUAL( result1, result2 ); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/qa/tests/common/text_eval/test_text_eval_render.cpp b/qa/tests/common/text_eval/test_text_eval_render.cpp new file mode 100644 index 0000000000..44643925d5 --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_render.cpp @@ -0,0 +1,54 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Test rendering helper functions with expression evaluation. + */ + +#include +#include +#include +#include + +BOOST_AUTO_TEST_SUITE( TextEvalRender ) + +BOOST_AUTO_TEST_CASE( GrTextWidthEval ) +{ + KIFONT::FONT* font = KIFONT::FONT::GetFont(); + VECTOR2I size( 100, 100 ); + int thickness = 1; + const KIFONT::METRICS& metrics = KIFONT::METRICS::Default(); + + int widthExpr = GRTextWidth( wxS( "@{1+1}" ), font, size, thickness, false, false, metrics ); + int widthExpected = KiROUND( font->StringBoundaryLimits( wxS( "2" ), size, thickness, false, + false, metrics ).x ); + int widthRaw = KiROUND( font->StringBoundaryLimits( wxS( "@{1+1}" ), size, thickness, false, + false, metrics ).x ); + + BOOST_CHECK_EQUAL( widthExpr, widthExpected ); + BOOST_CHECK( widthExpr != widthRaw ); +} + +BOOST_AUTO_TEST_SUITE_END() +