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/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 4ae43d4..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 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/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/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: 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/simplecpreprocessor/core.py b/simplecpreprocessor/core.py index 7e0d763..fb2ffe9 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): @@ -38,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, @@ -47,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 = [] @@ -60,68 +72,99 @@ 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): 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): 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, 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 - self.constraints.append((Tag.ELSE, constraint, ignore, line_no)) + 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: + frame.currently_active = False + + frame.tag = Tag.ELSE 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 not self.ignore and condition not in self.defines: - self.ignore = True - self.constraints.append((Tag.IFDEF, condition, True, line_no)) + + 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() + + 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"] 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) @@ -142,15 +185,26 @@ 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 not self.ignore and condition in self.defines: - self.ignore = True - self.constraints.append((Tag.IFNDEF, condition, True, line_no)) + + 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() + + 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"] @@ -160,8 +214,75 @@ 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))) + + 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: + 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.condition_stack: + fmt = "Unexpected #elif on line %s" + raise exceptions.ParseError(fmt % line_no) + + frame = self.condition_stack[-1] + + if frame.tag == Tag.ELSE: + fmt = "#elif after #else on line %s" + raise exceptions.ParseError(fmt % line_no) + + # 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: + frame.currently_active = False + + frame.tag = Tag.ELIF + + 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" @@ -303,14 +424,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) diff --git a/simplecpreprocessor/expression.py b/simplecpreprocessor/expression.py new file mode 100644 index 0000000..b9b8c30 --- /dev/null +++ b/simplecpreprocessor/expression.py @@ -0,0 +1,256 @@ +""" +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 (token := self.lexer.peek()) is not None: + 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: # pragma: no cover + 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..40c0c48 --- /dev/null +++ b/simplecpreprocessor/tests/test_expression.py @@ -0,0 +1,341 @@ +"""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) + + +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 new file mode 100644 index 0000000..1a86c88 --- /dev/null +++ b/simplecpreprocessor/tests/test_if_elif.py @@ -0,0 +1,396 @@ +"""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_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", + "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) + + +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))