From 753ced86da87024dbe6df02febc85f356cca7256 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:54:33 +0000 Subject: [PATCH 01/12] Initial plan From 410a22fbc99aa5ada0538edc9dbae54eb829687e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:59:24 +0000 Subject: [PATCH 02/12] Implement #if and #elif support with expression parser - Added expression.py module with Pratt parser for C preprocessor expressions - Supports arithmetic, logical, comparison, and bitwise operators - Supports defined() operator for checking macro definitions - Added process_if() and process_elif() methods to Preprocessor class - Added comprehensive unit tests for expression parsing - Added comprehensive unit tests for #if and #elif directives - All existing tests pass, 139 total tests with 96% coverage Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- simplecpreprocessor/core.py | 51 ++- simplecpreprocessor/expression.py | 260 ++++++++++++++ simplecpreprocessor/tests/test_expression.py | 268 ++++++++++++++ simplecpreprocessor/tests/test_if_elif.py | 356 +++++++++++++++++++ 4 files changed, 934 insertions(+), 1 deletion(-) create mode 100644 simplecpreprocessor/expression.py create mode 100644 simplecpreprocessor/tests/test_expression.py create mode 100644 simplecpreprocessor/tests/test_if_elif.py diff --git a/simplecpreprocessor/core.py b/simplecpreprocessor/core.py index 7e0d763..a4454a3 100644 --- a/simplecpreprocessor/core.py +++ b/simplecpreprocessor/core.py @@ -1,6 +1,6 @@ import enum -from . import filesystem, tokens, platform, exceptions +from . import filesystem, tokens, platform, exceptions, expression from .tokens import TokenType, is_string @@ -8,7 +8,9 @@ class Tag(enum.Enum): PRAGMA_ONCE = "#pragma_once" IFDEF = "#ifdef" IFNDEF = "#ifndef" + IF = "#if" ELSE = "#else" + ELIF = "#elif" def constants_to_token_constants(constants): @@ -160,6 +162,53 @@ def process_undef(self, **kwargs): del self.defines[undefine] return + def process_if(self, **kwargs): + chunk = kwargs["chunk"] + line_no = kwargs["line_no"] + try: + result = expression.evaluate_expression(chunk, self.defines) + condition_met = result != 0 + except (SyntaxError, ZeroDivisionError) as e: + fmt = "Error evaluating #if on line %s: %s" + raise exceptions.ParseError(fmt % (line_no, str(e))) + + if not self.ignore and not condition_met: + self.ignore = True + self.constraints.append((Tag.IF, result, True, line_no)) + else: + self.constraints.append((Tag.IF, result, False, line_no)) + + def process_elif(self, **kwargs): + chunk = kwargs["chunk"] + line_no = kwargs["line_no"] + if not self.constraints: + fmt = "Unexpected #elif on line %s" + raise exceptions.ParseError(fmt % line_no) + + constraint_type, constraint, ignore, original_line_no = ( + self.constraints.pop() + ) + + if constraint_type == Tag.ELSE: + fmt = "#elif after #else on line %s" + raise exceptions.ParseError(fmt % line_no) + + try: + result = expression.evaluate_expression(chunk, self.defines) + condition_met = result != 0 + except (SyntaxError, ZeroDivisionError) as e: + fmt = "Error evaluating #elif on line %s: %s" + raise exceptions.ParseError(fmt % (line_no, str(e))) + + if self.ignore and ignore and condition_met: + ignore = False + self.ignore = False + elif not ignore: + ignore = True + self.ignore = True + + self.constraints.append((Tag.ELIF, result, ignore, line_no)) + def process_source_chunks(self, chunk): if not self.ignore: for token in self.token_expander.expand_tokens(chunk): diff --git a/simplecpreprocessor/expression.py b/simplecpreprocessor/expression.py new file mode 100644 index 0000000..fbaeb32 --- /dev/null +++ b/simplecpreprocessor/expression.py @@ -0,0 +1,260 @@ +""" +Expression parser for C preprocessor #if and #elif directives. +Uses a Pratt parser for operator precedence parsing. +""" + + +class ExpressionToken: + """Token for expression parsing.""" + def __init__(self, type_, value): + self.type = type_ + self.value = value + + def __repr__(self): + return f"ExprToken({self.type}, {self.value!r})" + + +class ExpressionLexer: + """Lexer for C preprocessor expressions.""" + + def __init__(self, tokens): + """ + Initialize lexer with preprocessor tokens. + + Args: + tokens: List of Token objects from the preprocessor + """ + self.tokens = [] + i = 0 + non_ws_tokens = [t for t in tokens if not t.whitespace] + + # Combine multi-character operators + while i < len(non_ws_tokens): + token = non_ws_tokens[i] + + # Check for two-character operators + if i + 1 < len(non_ws_tokens): + next_token = non_ws_tokens[i + 1] + combined = token.value + next_token.value + if combined in ("&&", "||", "==", "!=", "<=", ">="): + # Create a combined token + from .tokens import Token, TokenType + combined_token = Token.from_string( + token.line_no, combined, TokenType.SYMBOL + ) + self.tokens.append(combined_token) + i += 2 + continue + + self.tokens.append(token) + i += 1 + + self.pos = 0 + + def peek(self): + """Return current token without advancing.""" + if self.pos < len(self.tokens): + return self.tokens[self.pos] + return None + + def consume(self): + """Consume and return current token.""" + token = self.peek() + self.pos += 1 + return token + + def at_end(self): + """Check if at end of tokens.""" + return self.pos >= len(self.tokens) + + +class ExpressionParser: + """ + Pratt parser for C preprocessor constant expressions. + Supports: integers, defined(), logical ops, comparison, arithmetic. + """ + + def __init__(self, tokens, defines): + """ + Initialize parser. + + Args: + tokens: List of Token objects from preprocessor + defines: Defines object to check for macro definitions + """ + self.lexer = ExpressionLexer(tokens) + self.defines = defines + + def parse(self): + """Parse and evaluate the expression, returning an integer.""" + if self.lexer.at_end(): + return 0 + result = self._parse_expr(0) + if not self.lexer.at_end(): + raise SyntaxError( + f"Unexpected token: {self.lexer.peek().value}" + ) + return result + + def _parse_expr(self, min_precedence): + """Parse expression with precedence climbing.""" + left = self._parse_primary() + + while True: + token = self.lexer.peek() + if token is None: + break + + op = token.value + # Stop at closing parenthesis + if op == ")": + break + + precedence = self._get_precedence(op) + if precedence <= 0 or precedence < min_precedence: + break + + self.lexer.consume() + right = self._parse_expr(precedence + 1) + left = self._apply_binary_op(op, left, right) + + return left + + def _parse_primary(self): + """Parse primary expression (numbers, defined, unary, parens).""" + token = self.lexer.peek() + if token is None: + raise SyntaxError("Unexpected end of expression") + + # Handle parentheses + if token.value == "(": + self.lexer.consume() + result = self._parse_expr(0) + closing = self.lexer.peek() + if closing is None or closing.value != ")": + raise SyntaxError("Missing closing parenthesis") + self.lexer.consume() + return result + + # Handle unary operators + if token.value in ("!", "+", "-"): + op = token.value + self.lexer.consume() + operand = self._parse_primary() + if op == "!": + return 0 if operand else 1 + elif op == "-": + return -operand + else: # + + return operand + + # Handle defined() operator + if token.value == "defined": + return self._parse_defined() + + # Handle integer literals + try: + value = int(token.value) + self.lexer.consume() + return value + except ValueError: + # Undefined identifier evaluates to 0 + self.lexer.consume() + return 0 + + def _parse_defined(self): + """Parse defined(MACRO) or defined MACRO.""" + self.lexer.consume() # consume 'defined' + + next_token = self.lexer.peek() + if next_token is None: + raise SyntaxError("Expected identifier after 'defined'") + + has_parens = next_token.value == "(" + if has_parens: + self.lexer.consume() + next_token = self.lexer.peek() + if next_token is None: + raise SyntaxError("Expected identifier in defined()") + + macro_name = next_token.value + self.lexer.consume() + + if has_parens: + closing = self.lexer.peek() + if closing is None or closing.value != ")": + raise SyntaxError("Missing closing paren in defined()") + self.lexer.consume() + + return 1 if macro_name in self.defines else 0 + + def _get_precedence(self, op): + """Get operator precedence (higher = binds tighter).""" + precedence_table = { + "||": 1, + "&&": 2, + "|": 3, + "^": 4, + "&": 5, + "==": 6, "!=": 6, + "<": 7, ">": 7, "<=": 7, ">=": 7, + "+": 8, "-": 8, + "*": 9, "/": 9, "%": 9, + } + return precedence_table.get(op, 0) + + def _apply_binary_op(self, op, left, right): + """Apply binary operator.""" + if op == "||": + return 1 if (left or right) else 0 + elif op == "&&": + return 1 if (left and right) else 0 + elif op == "|": + return left | right + elif op == "^": + return left ^ right + elif op == "&": + return left & right + elif op == "==": + return 1 if left == right else 0 + elif op == "!=": + return 1 if left != right else 0 + elif op == "<": + return 1 if left < right else 0 + elif op == ">": + return 1 if left > right else 0 + elif op == "<=": + return 1 if left <= right else 0 + elif op == ">=": + return 1 if left >= right else 0 + elif op == "+": + return left + right + elif op == "-": + return left - right + elif op == "*": + return left * right + elif op == "/": + if right == 0: + raise ZeroDivisionError("Division by zero") + return left // right + elif op == "%": + if right == 0: + raise ZeroDivisionError("Modulo by zero") + return left % right + else: + raise SyntaxError(f"Unknown operator: {op}") + + +def evaluate_expression(tokens, defines): + """ + Evaluate a C preprocessor constant expression. + + Args: + tokens: List of Token objects from the preprocessor + defines: Defines object to check for macro definitions + + Returns: + Integer result of the expression (non-zero = true, 0 = false) + """ + parser = ExpressionParser(tokens, defines) + return parser.parse() diff --git a/simplecpreprocessor/tests/test_expression.py b/simplecpreprocessor/tests/test_expression.py new file mode 100644 index 0000000..370052a --- /dev/null +++ b/simplecpreprocessor/tests/test_expression.py @@ -0,0 +1,268 @@ +"""Tests for expression parser.""" +from __future__ import absolute_import +import pytest +from simplecpreprocessor.expression import evaluate_expression +from simplecpreprocessor.core import Defines +from simplecpreprocessor.tokens import Token, TokenType + + +def make_tokens(values): + """Create Token objects from values.""" + return [ + Token.from_string(0, val, TokenType.IDENTIFIER) + for val in values + ] + + +def test_simple_integer(): + tokens = make_tokens(["42"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 42 + + +def test_simple_addition(): + tokens = make_tokens(["1", "+", "2"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 3 + + +def test_simple_subtraction(): + tokens = make_tokens(["5", "-", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 2 + + +def test_multiplication(): + tokens = make_tokens(["3", "*", "4"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 12 + + +def test_division(): + tokens = make_tokens(["10", "/", "2"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 5 + + +def test_modulo(): + tokens = make_tokens(["10", "%", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_precedence_multiply_before_add(): + tokens = make_tokens(["2", "+", "3", "*", "4"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 14 + + +def test_precedence_with_parentheses(): + tokens = make_tokens(["(", "2", "+", "3", ")", "*", "4"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 20 + + +def test_logical_and_true(): + tokens = make_tokens(["1", "&&", "1"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_logical_and_false(): + tokens = make_tokens(["1", "&&", "0"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_logical_or_true(): + tokens = make_tokens(["1", "||", "0"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_logical_or_false(): + tokens = make_tokens(["0", "||", "0"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_logical_not_true(): + tokens = make_tokens(["!", "0"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_logical_not_false(): + tokens = make_tokens(["!", "1"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_equal(): + tokens = make_tokens(["5", "==", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_not_equal(): + tokens = make_tokens(["5", "!=", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_less_than(): + tokens = make_tokens(["3", "<", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_greater_than(): + tokens = make_tokens(["5", ">", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_less_than_or_equal(): + tokens = make_tokens(["3", "<=", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_greater_than_or_equal(): + tokens = make_tokens(["5", ">=", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_bitwise_and(): + tokens = make_tokens(["5", "&", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_bitwise_or(): + tokens = make_tokens(["4", "|", "2"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 6 + + +def test_bitwise_xor(): + tokens = make_tokens(["5", "^", "3"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 6 + + +def test_unary_minus(): + tokens = make_tokens(["-", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == -5 + + +def test_unary_plus(): + tokens = make_tokens(["+", "5"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 5 + + +def test_defined_true(): + tokens = make_tokens(["defined", "(", "FOO", ")"]) + defines = Defines({"FOO": []}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_defined_false(): + tokens = make_tokens(["defined", "(", "FOO", ")"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_defined_without_parens(): + tokens = make_tokens(["defined", "FOO"]) + defines = Defines({"FOO": []}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_defined_without_parens_false(): + tokens = make_tokens(["defined", "BAR"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_complex_expression_with_defined(): + tokens = make_tokens( + ["defined", "(", "FOO", ")", "&&", "(", "1", "+", "2", ")", ">", "2"] + ) + defines = Defines({"FOO": []}) + result = evaluate_expression(tokens, defines) + assert result == 1 + + +def test_undefined_identifier_evaluates_to_zero(): + tokens = make_tokens(["UNDEFINED"]) + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_empty_expression(): + tokens = [] + defines = Defines({}) + result = evaluate_expression(tokens, defines) + assert result == 0 + + +def test_division_by_zero(): + tokens = make_tokens(["1", "/", "0"]) + defines = Defines({}) + with pytest.raises(ZeroDivisionError): + evaluate_expression(tokens, defines) + + +def test_modulo_by_zero(): + tokens = make_tokens(["1", "%", "0"]) + defines = Defines({}) + with pytest.raises(ZeroDivisionError): + evaluate_expression(tokens, defines) + + +def test_missing_closing_paren(): + tokens = make_tokens(["(", "1", "+", "2"]) + defines = Defines({}) + with pytest.raises(SyntaxError): + evaluate_expression(tokens, defines) + + +def test_missing_closing_paren_in_defined(): + tokens = make_tokens(["defined", "(", "FOO"]) + defines = Defines({}) + with pytest.raises(SyntaxError): + evaluate_expression(tokens, defines) diff --git a/simplecpreprocessor/tests/test_if_elif.py b/simplecpreprocessor/tests/test_if_elif.py new file mode 100644 index 0000000..ae72665 --- /dev/null +++ b/simplecpreprocessor/tests/test_if_elif.py @@ -0,0 +1,356 @@ +"""Tests for #if and #elif directives.""" +from __future__ import absolute_import +import pytest +from simplecpreprocessor import preprocess +from simplecpreprocessor.filesystem import FakeFile +from simplecpreprocessor.exceptions import ParseError + + +def run_case(input_list, expected): + ret = preprocess(input_list) + output = "".join(ret) + assert output == expected + + +def test_if_true_simple(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_false_simple(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "X\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_if_with_expression(): + f_obj = FakeFile("header.h", [ + "#if 2 + 3\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_comparison_true(): + f_obj = FakeFile("header.h", [ + "#if 5 > 3\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_comparison_false(): + f_obj = FakeFile("header.h", [ + "#if 3 > 5\n", + "X\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_if_with_defined_true(): + f_obj = FakeFile("header.h", [ + "#define FOO\n", + "#if defined(FOO)\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_defined_false(): + f_obj = FakeFile("header.h", [ + "#if defined(FOO)\n", + "X\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_if_with_defined_no_parens(): + f_obj = FakeFile("header.h", [ + "#define BAR\n", + "#if defined BAR\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_logical_and_true(): + f_obj = FakeFile("header.h", [ + "#define A\n", + "#define B\n", + "#if defined(A) && defined(B)\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_logical_and_false(): + f_obj = FakeFile("header.h", [ + "#define A\n", + "#if defined(A) && defined(B)\n", + "X\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_if_with_logical_or_true(): + f_obj = FakeFile("header.h", [ + "#define A\n", + "#if defined(A) || defined(B)\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_logical_not(): + f_obj = FakeFile("header.h", [ + "#if !defined(FOO)\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_else_true(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "X\n", + "#else\n", + "Y\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_else_false(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "X\n", + "#else\n", + "Y\n", + "#endif\n" + ]) + expected = "Y\n" + run_case(f_obj, expected) + + +def test_elif_first_true(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "A\n", + "#elif 1\n", + "B\n", + "#endif\n" + ]) + expected = "A\n" + run_case(f_obj, expected) + + +def test_elif_second_true(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 1\n", + "B\n", + "#endif\n" + ]) + expected = "B\n" + run_case(f_obj, expected) + + +def test_elif_all_false(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 0\n", + "B\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_elif_multiple(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 0\n", + "B\n", + "#elif 1\n", + "C\n", + "#endif\n" + ]) + expected = "C\n" + run_case(f_obj, expected) + + +def test_elif_with_else(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 0\n", + "B\n", + "#else\n", + "C\n", + "#endif\n" + ]) + expected = "C\n" + run_case(f_obj, expected) + + +def test_elif_with_defined(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif defined(FOO)\n", + "B\n", + "#else\n", + "C\n", + "#endif\n" + ]) + expected = "C\n" + run_case(f_obj, expected) + + +def test_elif_with_defined_true(): + f_obj = FakeFile("header.h", [ + "#define FOO\n", + "#if 0\n", + "A\n", + "#elif defined(FOO)\n", + "B\n", + "#else\n", + "C\n", + "#endif\n" + ]) + expected = "B\n" + run_case(f_obj, expected) + + +def test_nested_if(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "#if 1\n", + "X\n", + "#endif\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_nested_if_outer_false(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "#if 1\n", + "X\n", + "#endif\n", + "#endif\n" + ]) + expected = "" + run_case(f_obj, expected) + + +def test_if_left_open_causes_error(): + f_obj = FakeFile("header.h", ["#if 1\n"]) + with pytest.raises(ParseError) as excinfo: + "".join(preprocess(f_obj)) + s = str(excinfo.value) + assert "if" in s.lower() + assert "left open" in s + + +def test_elif_without_if(): + f_obj = FakeFile("header.h", ["#elif 1\n"]) + with pytest.raises(ParseError) as excinfo: + "".join(preprocess(f_obj)) + assert "Unexpected #elif" in str(excinfo.value) + + +def test_elif_after_else(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "X\n", + "#else\n", + "Y\n", + "#elif 1\n", + "Z\n", + "#endif\n" + ]) + with pytest.raises(ParseError) as excinfo: + "".join(preprocess(f_obj)) + assert "#elif after #else" in str(excinfo.value) + + +def test_if_with_parentheses(): + f_obj = FakeFile("header.h", [ + "#if (1 + 2) * 3\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_complex_expression(): + f_obj = FakeFile("header.h", [ + "#define A\n", + "#if defined(A) && (1 + 1 == 2)\n", + "X\n", + "#endif\n" + ]) + expected = "X\n" + run_case(f_obj, expected) + + +def test_if_with_define_expansion(): + f_obj = FakeFile("header.h", [ + "#if 1\n", + "#define X value\n", + "#endif\n", + "X\n" + ]) + expected = "value\n" + run_case(f_obj, expected) + + +def test_elif_stops_at_first_true(): + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 1\n", + "B\n", + "#elif 1\n", + "C\n", + "#endif\n" + ]) + expected = "B\n" + run_case(f_obj, expected) From b2589d0ecf5021f71ba496afdeee5fd3310b969a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:59:39 +0000 Subject: [PATCH 03/12] Update README to document #if and #elif support Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9640e15..c573cfe 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,18 @@ behaviour. Gotchas --------- -Supported macros: ifdef, ifndef, define, undef, include, else, +Supported macros: ifdef, ifndef, if, elif, define, undef, include, else, pragma (only "once") +The #if and #elif directives support constant expression evaluation including: + * Integer constants + * Arithmetic operators: +, -, *, /, % + * Comparison operators: ==, !=, <, >, <=, >= + * Logical operators: &&, ||, ! + * Bitwise operators: &, |, ^ + * The defined() operator (with or without parentheses) + * Parentheses for grouping + If using for FFI, you may want to ignore some system headers eg for types Limitations: From fa7e59043240055bff5ed574631c090ea1b3f286 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:03:53 +0000 Subject: [PATCH 04/12] Fix #elif and #else logic for if/elif chains - Changed constraint field to track whether a branch was taken (boolean) - Updated process_if to store branch_taken instead of expression result - Updated process_elif to propagate branch_taken flag correctly - Updated process_else to check branch_taken for IF/ELIF constraints - All 139 tests pass with 96% coverage Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- simplecpreprocessor/core.py | 80 +++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/simplecpreprocessor/core.py b/simplecpreprocessor/core.py index a4454a3..b40f64a 100644 --- a/simplecpreprocessor/core.py +++ b/simplecpreprocessor/core.py @@ -88,13 +88,30 @@ def process_else(self, **kwargs): if not self.constraints: fmt = "Unexpected #else on line %s" raise exceptions.ParseError(fmt % line_no) - _, constraint, ignore, _ = self.constraints.pop() - if self.ignore and ignore: - ignore = False - self.ignore = False - elif not self.ignore and not ignore: - ignore = True - self.ignore = True + constraint_type, constraint, ignore, _ = self.constraints.pop() + + # For IF/ELIF, check if any branch was taken + if constraint_type in (Tag.IF, Tag.ELIF): + branch_was_taken = constraint + if branch_was_taken: + # A branch was already taken, don't take else + ignore = True + if not self.ignore: + self.ignore = True + else: + # No branch was taken, take the else + ignore = False + if self.ignore: + self.ignore = False + else: + # Original logic for IFDEF/IFNDEF + if self.ignore and ignore: + ignore = False + self.ignore = False + elif not self.ignore and not ignore: + ignore = True + self.ignore = True + self.constraints.append((Tag.ELSE, constraint, ignore, line_no)) def process_ifdef(self, **kwargs): @@ -172,11 +189,13 @@ def process_if(self, **kwargs): fmt = "Error evaluating #if on line %s: %s" raise exceptions.ParseError(fmt % (line_no, str(e))) + # Store whether this branch was taken in the constraint field + branch_taken = condition_met if not self.ignore and not condition_met: self.ignore = True - self.constraints.append((Tag.IF, result, True, line_no)) + self.constraints.append((Tag.IF, branch_taken, True, line_no)) else: - self.constraints.append((Tag.IF, result, False, line_no)) + self.constraints.append((Tag.IF, branch_taken, False, line_no)) def process_elif(self, **kwargs): chunk = kwargs["chunk"] @@ -193,21 +212,40 @@ def process_elif(self, **kwargs): fmt = "#elif after #else on line %s" raise exceptions.ParseError(fmt % line_no) - try: - result = expression.evaluate_expression(chunk, self.defines) - condition_met = result != 0 - except (SyntaxError, ZeroDivisionError) as e: - fmt = "Error evaluating #elif on line %s: %s" - raise exceptions.ParseError(fmt % (line_no, str(e))) + # Check if any previous branch was taken + # For IF/ELIF, constraint stores whether that branch was taken + previous_branch_taken = constraint if constraint_type in ( + Tag.IF, Tag.ELIF + ) else (not ignore) - if self.ignore and ignore and condition_met: - ignore = False - self.ignore = False - elif not ignore: + if previous_branch_taken: + # A previous branch was taken, so ignore this elif + branch_taken = True # Mark that a branch was taken ignore = True - self.ignore = True + if not self.ignore: + self.ignore = True + else: + # No previous branch taken, evaluate this elif's condition + try: + result = expression.evaluate_expression(chunk, self.defines) + condition_met = result != 0 + except (SyntaxError, ZeroDivisionError) as e: + fmt = "Error evaluating #elif on line %s: %s" + raise exceptions.ParseError(fmt % (line_no, str(e))) + + if condition_met: + # This branch's condition is met + branch_taken = True + ignore = False + if self.ignore: + self.ignore = False + else: + # This branch's condition is not met + branch_taken = False + ignore = True + # self.ignore should already be True - self.constraints.append((Tag.ELIF, result, ignore, line_no)) + self.constraints.append((Tag.ELIF, branch_taken, ignore, line_no)) def process_source_chunks(self, chunk): if not self.ignore: From c746216680a0413be4f96eb00ee325449c18504d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:17:19 +0000 Subject: [PATCH 05/12] Refactor to stack-based condition frames for clearer logic - Replaced single self.ignore variable with condition_stack - Created ConditionFrame class to track each conditional block - Each frame tracks: tag, condition, line_no, branch_taken, currently_active - Added _should_ignore() helper to check if content should be ignored - Simplified all process_* methods to work with frames instead of constraints - Logic is now more maintainable: each frame's state is explicit - All 139 tests pass, 95% coverage maintained Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- simplecpreprocessor/core.py | 196 +++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 90 deletions(-) diff --git a/simplecpreprocessor/core.py b/simplecpreprocessor/core.py index b40f64a..75725ab 100644 --- a/simplecpreprocessor/core.py +++ b/simplecpreprocessor/core.py @@ -40,6 +40,17 @@ def __contains__(self, key): return key in self.defines +class ConditionFrame: + """Represents a conditional compilation block (#if/#ifdef/#ifndef).""" + + def __init__(self, tag, condition, line_no): + self.tag = tag + self.condition = condition + self.line_no = line_no + self.branch_taken = False + self.currently_active = False + + class Preprocessor: def __init__(self, line_ending=tokens.DEFAULT_LINE_ENDING, @@ -49,8 +60,7 @@ def __init__(self, line_ending=tokens.DEFAULT_LINE_ENDING, self.ignore_headers = ignore_headers self.include_once = {} self.defines = Defines(platform_constants) - self.constraints = [] - self.ignore = False + self.condition_stack = [] self.line_ending = line_ending self.last_constraint = None self.header_stack = [] @@ -62,8 +72,15 @@ def __init__(self, line_ending=tokens.DEFAULT_LINE_ENDING, self.headers = header_handler self.headers.add_include_paths(include_paths) + def _should_ignore(self): + """Check if we should ignore content at the current nesting level.""" + for frame in self.condition_stack: + if not frame.currently_active: + return True + return False + def process_define(self, **kwargs): - if self.ignore: + if self._should_ignore(): return chunk = kwargs["chunk"] for i, tokenized in enumerate(chunk): @@ -74,45 +91,33 @@ def process_define(self, **kwargs): def process_endif(self, **kwargs): line_no = kwargs["line_no"] - if not self.constraints: + if not self.condition_stack: fmt = "Unexpected #endif on line %s" raise exceptions.ParseError(fmt % line_no) - (constraint_type, constraint, ignore, - original_line_no) = self.constraints.pop() - if ignore: - self.ignore = False - self.last_constraint = constraint, constraint_type, original_line_no + frame = self.condition_stack.pop() + self.last_constraint = ( + frame.condition, frame.tag, frame.line_no + ) def process_else(self, **kwargs): line_no = kwargs["line_no"] - if not self.constraints: + if not self.condition_stack: fmt = "Unexpected #else on line %s" raise exceptions.ParseError(fmt % line_no) - constraint_type, constraint, ignore, _ = self.constraints.pop() - - # For IF/ELIF, check if any branch was taken - if constraint_type in (Tag.IF, Tag.ELIF): - branch_was_taken = constraint - if branch_was_taken: - # A branch was already taken, don't take else - ignore = True - if not self.ignore: - self.ignore = True - else: - # No branch was taken, take the else - ignore = False - if self.ignore: - self.ignore = False + frame = self.condition_stack[-1] + + if frame.tag == Tag.ELSE: + fmt = "#else after #else on line %s" + raise exceptions.ParseError(fmt % line_no) + + # Take the else branch only if no previous branch was taken + if not frame.branch_taken: + frame.currently_active = True + frame.branch_taken = True else: - # Original logic for IFDEF/IFNDEF - if self.ignore and ignore: - ignore = False - self.ignore = False - elif not self.ignore and not ignore: - ignore = True - self.ignore = True + frame.currently_active = False - self.constraints.append((Tag.ELSE, constraint, ignore, line_no)) + frame.tag = Tag.ELSE def process_ifdef(self, **kwargs): chunk = kwargs["chunk"] @@ -121,11 +126,17 @@ def process_ifdef(self, **kwargs): if not token.whitespace: condition = token.value break - if not self.ignore and condition not in self.defines: - self.ignore = True - self.constraints.append((Tag.IFDEF, condition, True, line_no)) + + frame = ConditionFrame(Tag.IFDEF, condition, line_no) + parent_ignoring = self._should_ignore() + + if not parent_ignoring and condition in self.defines: + frame.currently_active = True + frame.branch_taken = True else: - self.constraints.append((Tag.IFDEF, condition, False, line_no)) + frame.currently_active = False + + self.condition_stack.append(frame) def process_pragma(self, **kwargs): chunk = kwargs["chunk"] @@ -165,11 +176,17 @@ def process_ifndef(self, **kwargs): if not token.whitespace: condition = token.value break - if not self.ignore and condition in self.defines: - self.ignore = True - self.constraints.append((Tag.IFNDEF, condition, True, line_no)) + + frame = ConditionFrame(Tag.IFNDEF, condition, line_no) + parent_ignoring = self._should_ignore() + + if not parent_ignoring and condition not in self.defines: + frame.currently_active = True + frame.branch_taken = True else: - self.constraints.append((Tag.IFNDEF, condition, False, line_no)) + frame.currently_active = False + + self.condition_stack.append(frame) def process_undef(self, **kwargs): chunk = kwargs["chunk"] @@ -189,66 +206,65 @@ def process_if(self, **kwargs): fmt = "Error evaluating #if on line %s: %s" raise exceptions.ParseError(fmt % (line_no, str(e))) - # Store whether this branch was taken in the constraint field - branch_taken = condition_met - if not self.ignore and not condition_met: - self.ignore = True - self.constraints.append((Tag.IF, branch_taken, True, line_no)) + frame = ConditionFrame(Tag.IF, result, line_no) + parent_ignoring = self._should_ignore() + + if not parent_ignoring and condition_met: + frame.currently_active = True + frame.branch_taken = True else: - self.constraints.append((Tag.IF, branch_taken, False, line_no)) + frame.currently_active = False + + self.condition_stack.append(frame) def process_elif(self, **kwargs): chunk = kwargs["chunk"] line_no = kwargs["line_no"] - if not self.constraints: + if not self.condition_stack: fmt = "Unexpected #elif on line %s" raise exceptions.ParseError(fmt % line_no) - constraint_type, constraint, ignore, original_line_no = ( - self.constraints.pop() - ) + frame = self.condition_stack[-1] - if constraint_type == Tag.ELSE: + if frame.tag == Tag.ELSE: fmt = "#elif after #else on line %s" raise exceptions.ParseError(fmt % line_no) - # Check if any previous branch was taken - # For IF/ELIF, constraint stores whether that branch was taken - previous_branch_taken = constraint if constraint_type in ( - Tag.IF, Tag.ELIF - ) else (not ignore) - - if previous_branch_taken: - # A previous branch was taken, so ignore this elif - branch_taken = True # Mark that a branch was taken - ignore = True - if not self.ignore: - self.ignore = True + # If a previous branch was taken, skip this elif + if frame.branch_taken: + frame.currently_active = False + frame.tag = Tag.ELIF + return + + # No previous branch taken, evaluate this elif's condition + try: + result = expression.evaluate_expression(chunk, self.defines) + condition_met = result != 0 + except (SyntaxError, ZeroDivisionError) as e: + fmt = "Error evaluating #elif on line %s: %s" + raise exceptions.ParseError(fmt % (line_no, str(e))) + + parent_ignoring = self._should_ignore_at_level( + len(self.condition_stack) - 1 + ) + + if not parent_ignoring and condition_met: + frame.currently_active = True + frame.branch_taken = True else: - # No previous branch taken, evaluate this elif's condition - try: - result = expression.evaluate_expression(chunk, self.defines) - condition_met = result != 0 - except (SyntaxError, ZeroDivisionError) as e: - fmt = "Error evaluating #elif on line %s: %s" - raise exceptions.ParseError(fmt % (line_no, str(e))) - - if condition_met: - # This branch's condition is met - branch_taken = True - ignore = False - if self.ignore: - self.ignore = False - else: - # This branch's condition is not met - branch_taken = False - ignore = True - # self.ignore should already be True + frame.currently_active = False + + frame.tag = Tag.ELIF - self.constraints.append((Tag.ELIF, branch_taken, ignore, line_no)) + def _should_ignore_at_level(self, level): + """Check if we should ignore at a specific stack level.""" + for i in range(level): + if not self.condition_stack[i].currently_active: + return True + return False def process_source_chunks(self, chunk): - if not self.ignore: + if not self._should_ignore(): for token in self.token_expander.expand_tokens(chunk): if self.fold_strings_to_null and is_string(token): yield "NULL" @@ -390,14 +406,14 @@ def preprocess(self, f_object, depth=0): yield token self.check_fullfile_guard() self.header_stack.pop() - if not self.header_stack and self.constraints: - constraint_type, name, _, line_no = self.constraints[-1] + if not self.header_stack and self.condition_stack: + frame = self.condition_stack[-1] fmt = ( "{tag} {name} from line {line_no} left open" .format( - tag=constraint_type.value, - name=name, - line_no=line_no + tag=frame.tag.value, + name=frame.condition, + line_no=frame.line_no ) ) raise exceptions.ParseError(fmt) From fbb2e8e890ce348747efe46158a2cc320e4f0f02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:28:11 +0000 Subject: [PATCH 06/12] Use walrus operator in expression parser loop Applied walrus operator (:=) in _parse_expr to combine token assignment and None check as suggested in code review. Changed from: while True: token = self.lexer.peek(); if token is None: break To: while (token := self.lexer.peek()) is not None: All 139 tests pass, linting clean. Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- simplecpreprocessor/expression.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/simplecpreprocessor/expression.py b/simplecpreprocessor/expression.py index fbaeb32..b997dce 100644 --- a/simplecpreprocessor/expression.py +++ b/simplecpreprocessor/expression.py @@ -100,11 +100,7 @@ def _parse_expr(self, min_precedence): """Parse expression with precedence climbing.""" left = self._parse_primary() - while True: - token = self.lexer.peek() - if token is None: - break - + while (token := self.lexer.peek()) is not None: op = token.value # Stop at closing parenthesis if op == ")": From 40efbbc614cd1e5ee500644090e37b01d6ddd005 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:40:43 +0000 Subject: [PATCH 07/12] Add codecov.yml and update coverage documentation - Created codecov.yml to ensure consistent coverage reporting between local and CI/codecov - Configured codecov to ignore same files as .coveragerc - Set thresholds and precision to match local settings - Updated CONTRIBUTING.md to document the exact CI coverage command including XML report generation - Both local and codecov now use: py.test -v --cov=simplecpreprocessor --cov-config .coveragerc --cov-report=xml --cov-report=term-missing - This ensures coverage numbers are consistent across environments Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- CONTRIBUTING.md | 6 +- codecov.yml | 28 ++ coverage.xml | 695 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 codecov.yml create mode 100644 coverage.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0a1ab1..a368303 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,10 +42,12 @@ All contributions must pass linting with no errors. Run tests with coverage (matches CI): ```bash - py.test -v --cov=simplecpreprocessor --cov-config .coveragerc --cov-report=term-missing +py.test -v --cov=simplecpreprocessor --cov-config .coveragerc --cov-report=xml --cov-report=term-missing ``` -Coverage must remain at or above the current threshold. Coverage reports are generated automatically in CI. +This generates both a terminal report and an XML report (`coverage.xml`) that codecov uses. + +Coverage must remain at or above the current threshold. Coverage reports are generated automatically in CI and uploaded to codecov. ## Pull requests diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..2723539 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,28 @@ +codecov: + require_ci_to_pass: true + +coverage: + precision: 2 + round: down + range: "90...100" + + status: + project: + default: + target: auto + threshold: 0.5% + patch: + default: + target: auto + threshold: 0.5% + +ignore: + - "simplecpreprocessor/__init__.py" + - "simplecpreprocessor/__main__.py" + - "simplecpreprocessor/tests" + - "simplecpreprocessor/tests/*.py" + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: false diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..ca5cf9d --- /dev/null +++ b/coverage.xml @@ -0,0 +1,695 @@ + + + + + + /home/runner/work/simplecpreprocessor/simplecpreprocessor/simplecpreprocessor + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d5d03966c3e168b94628a90daea1ce2178f685b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:41:14 +0000 Subject: [PATCH 08/12] Add coverage.xml to .gitignore Removed coverage.xml from repository as it's a generated artifact. Added it to .gitignore to prevent accidental commits in the future. Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- .gitignore | 1 + coverage.xml | 695 --------------------------------------------------- 2 files changed, 1 insertion(+), 695 deletions(-) delete mode 100644 coverage.xml diff --git a/.gitignore b/.gitignore index 2e9253a..1ef5456 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc .coverage +coverage.xml dist htmlcov .venv diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index ca5cf9d..0000000 --- a/coverage.xml +++ /dev/null @@ -1,695 +0,0 @@ - - - - - - /home/runner/work/simplecpreprocessor/simplecpreprocessor/simplecpreprocessor - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 4069aa49b4ee7f3ced8f0e02b321cb1f0c72d704 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:03:41 +0000 Subject: [PATCH 09/12] Improve test coverage to 98% with comprehensive edge case tests - Added 10 new tests for expression parser edge cases (now 100% coverage) - Added tests for #if/#elif error handling (invalid expressions) - Added test for #else after #else error case - Added pragma: no cover to defensive but impossible cases in core.py - ExpressionToken class now fully tested (repr and attributes) - All error paths in expression parser now covered - Coverage improved from 95% to 98% overall - expression.py: 94% -> 100% coverage - core.py: 94% -> 97% coverage - All 149 tests pass, flake8 clean Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- simplecpreprocessor/core.py | 26 +++++-- simplecpreprocessor/expression.py | 2 +- simplecpreprocessor/tests/test_expression.py | 73 ++++++++++++++++++++ simplecpreprocessor/tests/test_if_elif.py | 40 +++++++++++ 4 files changed, 136 insertions(+), 5 deletions(-) diff --git a/simplecpreprocessor/core.py b/simplecpreprocessor/core.py index 75725ab..fb2ffe9 100644 --- a/simplecpreprocessor/core.py +++ b/simplecpreprocessor/core.py @@ -87,6 +87,9 @@ def process_define(self, **kwargs): if not tokenized.whitespace: define_name = tokenized.value break + else: # pragma: no cover + # Defensive: should never happen as tokenizer ensures non-ws tokens + return self.defines[define_name] = chunk[i+2:-1] def process_endif(self, **kwargs): @@ -122,11 +125,16 @@ def process_else(self, **kwargs): def process_ifdef(self, **kwargs): chunk = kwargs["chunk"] line_no = kwargs["line_no"] + condition = None for token in chunk: if not token.whitespace: condition = token.value break + if condition is None: # pragma: no cover + # Defensive: should never happen as tokenizer ensures non-ws tokens + return + frame = ConditionFrame(Tag.IFDEF, condition, line_no) parent_ignoring = self._should_ignore() @@ -142,16 +150,21 @@ def process_pragma(self, **kwargs): chunk = kwargs["chunk"] line_no = kwargs["line_no"] pragma = None + token = None for token in chunk: if not token.whitespace: method_name = "process_pragma_%s" % token.value pragma = getattr(self, method_name, None) break if pragma is None: - s = ( - "Unsupported pragma %s on line %s" - % (token.value, line_no) - ) + if token is None: # pragma: no cover + # Defensive: should never happen + s = "Unsupported pragma on line %s" % line_no + else: + s = ( + "Unsupported pragma %s on line %s" + % (token.value, line_no) + ) raise exceptions.ParseError(s) else: ret = pragma(chunk=chunk, line_no=line_no) @@ -172,11 +185,16 @@ def current_name(self): def process_ifndef(self, **kwargs): chunk = kwargs["chunk"] line_no = kwargs["line_no"] + condition = None for token in chunk: if not token.whitespace: condition = token.value break + if condition is None: # pragma: no cover + # Defensive: should never happen as tokenizer ensures non-ws tokens + return + frame = ConditionFrame(Tag.IFNDEF, condition, line_no) parent_ignoring = self._should_ignore() diff --git a/simplecpreprocessor/expression.py b/simplecpreprocessor/expression.py index b997dce..b9b8c30 100644 --- a/simplecpreprocessor/expression.py +++ b/simplecpreprocessor/expression.py @@ -237,7 +237,7 @@ def _apply_binary_op(self, op, left, right): if right == 0: raise ZeroDivisionError("Modulo by zero") return left % right - else: + else: # pragma: no cover raise SyntaxError(f"Unknown operator: {op}") diff --git a/simplecpreprocessor/tests/test_expression.py b/simplecpreprocessor/tests/test_expression.py index 370052a..40c0c48 100644 --- a/simplecpreprocessor/tests/test_expression.py +++ b/simplecpreprocessor/tests/test_expression.py @@ -266,3 +266,76 @@ def test_missing_closing_paren_in_defined(): defines = Defines({}) with pytest.raises(SyntaxError): evaluate_expression(tokens, defines) + + +def test_unexpected_token_after_expression(): + """Test that extra tokens after a complete expression raise an error.""" + tokens = make_tokens(["1", "+", "2", "extra"]) + defines = Defines({}) + with pytest.raises(SyntaxError, match="Unexpected token"): + evaluate_expression(tokens, defines) + + +def test_unexpected_end_in_parse_primary(): + """Test parse_primary when token stream ends unexpectedly.""" + # This tests the case where we're in the middle of parsing and run out + # Creating an expression that consumes all tokens in _parse_primary + tokens = make_tokens(["1", "+"]) + defines = Defines({}) + with pytest.raises(SyntaxError, match="Unexpected end of expression"): + evaluate_expression(tokens, defines) + + +def test_defined_without_identifier(): + """Test defined() with no identifier following.""" + # Create tokens: just "defined" with nothing after + tokens = [Token.from_string(0, "defined", TokenType.IDENTIFIER)] + defines = Defines({}) + with pytest.raises(SyntaxError, match="Expected identifier after"): + evaluate_expression(tokens, defines) + + +def test_defined_with_parens_no_identifier(): + """Test defined( with no identifier inside parentheses.""" + # Create tokens: "defined" "(" ")" - this will use ")" as identifier + # and then fail to find closing paren + tokens = [ + Token.from_string(0, "defined", TokenType.IDENTIFIER), + Token.from_string(0, "(", TokenType.SYMBOL), + Token.from_string(0, ")", TokenType.SYMBOL) + ] + defines = Defines({}) + with pytest.raises(SyntaxError, match="Missing closing paren"): + evaluate_expression(tokens, defines) + + +def test_defined_with_parens_truncated(): + """Test defined( with token stream ending.""" + # This tests line 174 - when we have "defined (" but no more tokens + from simplecpreprocessor.tokens import Token, TokenType + + # Manually create scenario where after "defined (", no more tokens + tokens = [ + Token.from_string(0, "defined", TokenType.IDENTIFIER), + Token.from_string(0, "(", TokenType.SYMBOL) + ] + defines = Defines({}) + with pytest.raises(SyntaxError, match="Expected identifier in defined"): + evaluate_expression(tokens, defines) + + +def test_expression_token_repr(): + """Test ExpressionToken __repr__ for coverage.""" + from simplecpreprocessor.expression import ExpressionToken + token = ExpressionToken("NUMBER", "42") + assert "ExprToken" in repr(token) + assert "NUMBER" in repr(token) + assert "42" in repr(token) + + +def test_expression_token_attributes(): + """Test ExpressionToken attributes for coverage.""" + from simplecpreprocessor.expression import ExpressionToken + token = ExpressionToken("NUMBER", "42") + assert token.type == "NUMBER" + assert token.value == "42" diff --git a/simplecpreprocessor/tests/test_if_elif.py b/simplecpreprocessor/tests/test_if_elif.py index ae72665..1a86c88 100644 --- a/simplecpreprocessor/tests/test_if_elif.py +++ b/simplecpreprocessor/tests/test_if_elif.py @@ -310,6 +310,22 @@ def test_elif_after_else(): assert "#elif after #else" in str(excinfo.value) +def test_else_after_else(): + """Test that #else after #else raises an error.""" + f_obj = FakeFile("header.h", [ + "#if 1\n", + "X\n", + "#else\n", + "Y\n", + "#else\n", + "Z\n", + "#endif\n" + ]) + with pytest.raises(ParseError) as excinfo: + "".join(preprocess(f_obj)) + assert "#else after #else" in str(excinfo.value) + + def test_if_with_parentheses(): f_obj = FakeFile("header.h", [ "#if (1 + 2) * 3\n", @@ -354,3 +370,27 @@ def test_elif_stops_at_first_true(): ]) expected = "B\n" run_case(f_obj, expected) + + +def test_if_with_invalid_expression(): + """Test #if with syntax error in expression.""" + f_obj = FakeFile("header.h", [ + "#if 1 (\n", + "X\n", + "#endif\n" + ]) + with pytest.raises(ParseError, match="Error evaluating #if"): + "".join(preprocess(f_obj)) + + +def test_elif_with_invalid_expression(): + """Test #elif with syntax error in expression.""" + f_obj = FakeFile("header.h", [ + "#if 0\n", + "A\n", + "#elif 1 / 0\n", + "B\n", + "#endif\n" + ]) + with pytest.raises(ParseError, match="Error evaluating #elif"): + "".join(preprocess(f_obj)) From 66694c31f66a47f162e849ebe83dfe2cf418fc83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:21:58 +0000 Subject: [PATCH 10/12] Fix codecov path mapping issue with relative_files option - Added relative_files = True to .coveragerc [run] section - This ensures coverage.xml uses relative paths instead of absolute paths - Changed source path from absolute to relative: simplecpreprocessor - Updated codecov.yml with fixes directive for path normalization - This fixes codecov's inability to detect coverage due to path mismatch - All 149 tests pass, 98% coverage maintained Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- .coveragerc | 4 ++++ codecov.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.coveragerc b/.coveragerc index 5709624..d1fc3a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,10 @@ [run] branch = True +relative_files = True omit = simplecpreprocessor/__init__.py simplecpreprocessor/__main__.py simplecpreprocessor/tests/*.py + +[xml] +output = coverage.xml diff --git a/codecov.yml b/codecov.yml index 2723539..c4d6cbc 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,6 +16,9 @@ coverage: target: auto threshold: 0.5% +fixes: + - "simplecpreprocessor/::" + ignore: - "simplecpreprocessor/__init__.py" - "simplecpreprocessor/__main__.py" From bad0b0e281782a567f003745d8efd2b99253b311 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:32:55 +0000 Subject: [PATCH 11/12] Fix codecov workflow configuration with root_dir - Removed incorrect 'fixes' directive from codecov.yml - Added root_dir parameter to codecov action in workflow - This ensures codecov can correctly map file paths in coverage.xml to repository files - The workflow now explicitly sets the workspace root for codecov - All 149 tests pass, 98% coverage maintained Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- .github/workflows/ci-build.yml | 1 + codecov.yml | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 4ae43d4..ec16b90 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -74,3 +74,4 @@ jobs: use_oidc: true fail_ci_if_error: true verbose: true + root_dir: ${{ github.workspace }} diff --git a/codecov.yml b/codecov.yml index c4d6cbc..2723539 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,9 +16,6 @@ coverage: target: auto threshold: 0.5% -fixes: - - "simplecpreprocessor/::" - ignore: - "simplecpreprocessor/__init__.py" - "simplecpreprocessor/__main__.py" From cbd141179b1b82629d30996442f8cea1f9b710b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:03:17 +0000 Subject: [PATCH 12/12] Restructure CI workflow to run coverage tests in upload job - Removed artifact upload/download mechanism that was causing issues - Regular test job now runs tests without coverage (faster) - upload-coverage job runs tests with coverage and uploads directly to codecov - This avoids file path issues from artifact download - coverage.xml is generated in the same workspace where codecov runs - Removed root_dir parameter as it's no longer needed - All 149 tests pass, 98% coverage maintained Co-authored-by: nanonyme <348449+nanonyme@users.noreply.github.com> --- .github/workflows/ci-build.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ec16b90..2e4362f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -35,7 +35,7 @@ jobs: flake8 simplecpreprocessor test: - name: Test with coverage + name: Run tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.7 @@ -43,17 +43,10 @@ jobs: - uses: ./.github/actions/prepare-build - run: | - py.test -v --cov=simplecpreprocessor --cov-config .coveragerc --cov-report=xml --cov-report=term-missing - - - name: Upload coverage artifact - uses: actions/upload-artifact@v4.3.3 - with: - name: coverage - path: coverage.xml + py.test -v upload-coverage: - name: Publish coverage report - needs: test + name: Test with coverage and publish report # Disabled for fork PR's as we can't use OIDC there if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest @@ -63,9 +56,11 @@ jobs: steps: - uses: actions/checkout@v4.1.7 - - uses: actions/download-artifact@v4.1.8 - with: - name: coverage + - uses: ./.github/actions/prepare-build + + - name: Run tests with coverage + run: | + py.test -v --cov=simplecpreprocessor --cov-config .coveragerc --cov-report=xml --cov-report=term-missing - name: Upload to Codecov via OIDC uses: codecov/codecov-action@v5.5.0 @@ -74,4 +69,3 @@ jobs: use_oidc: true fail_ci_if_error: true verbose: true - root_dir: ${{ github.workspace }}