diff --git a/.coverage b/.coverage deleted file mode 100644 index e8a719a..0000000 Binary files a/.coverage and /dev/null differ diff --git a/asciidoc_linter/reporter.py b/asciidoc_linter/reporter.py index 65fc3e5..1d3481b 100755 --- a/asciidoc_linter/reporter.py +++ b/asciidoc_linter/reporter.py @@ -3,96 +3,94 @@ Different output formatters for lint results """ +from dataclasses import dataclass +from typing import List, Optional import json -from abc import ABC, abstractmethod -from typing import List -from .rules import Finding -class Reporter(ABC): - """Base class for all reporters""" - - @abstractmethod - def report(self, findings: List[Finding]) -> str: - """Format and return the findings""" - pass +@dataclass +class LintError: + """Represents a single lint error""" + file: Optional[str] + line: int + message: str + +@dataclass +class LintReport: + """Contains all lint errors for a document""" + errors: List[LintError] + + def __bool__(self): + return bool(self.errors) -class ConsoleReporter(Reporter): - """Reports findings to the console in a human-readable format""" + def __len__(self): + return len(self.errors) + +class Reporter: + """Base class for formatting lint reports""" - def report(self, findings: List[Finding]) -> str: + def format_report(self, report: LintReport) -> str: + """Format the report as string""" output = [] - for finding in findings: - severity = finding.severity.value.upper() - location = f"line {finding.position.line}" - if finding.position.column: - location += f", column {finding.position.column}" + for error in report.errors: + location = f"line {error.line}" + if error.file: + location = f"{error.file}:{location}" - output.append( - f"{severity}: {finding.message} ({finding.rule_id})" - f"\n at {location}" - ) - if finding.context: - output.append(f" context: {finding.context}") + output.append(f"{location}: {error.message}") return "\n".join(output) class JsonReporter(Reporter): """Reports findings in JSON format""" - def report(self, findings: List[Finding]) -> str: + def format_report(self, report: LintReport) -> str: return json.dumps([ { - 'rule_id': f.rule_id, - 'message': f.message, - 'severity': f.severity.value, - 'position': { - 'line': f.position.line, - 'column': f.position.column - }, - 'context': f.context + 'file': error.file, + 'line': error.line, + 'message': error.message } - for f in findings + for error in report.errors ], indent=2) class HtmlReporter(Reporter): """Reports findings in HTML format""" - def report(self, findings: List[Finding]) -> str: + def format_report(self, report: LintReport) -> str: rows = [] - for f in findings: - severity_class = f"severity-{f.severity.value}" + for error in report.errors: + location = f"Line {error.line}" + if error.file: + location = f"{error.file}:{location}" + rows.append( - f'' - f'{f.severity.value.upper()}' - f'{f.rule_id}' - f'{f.message}' - f'Line {f.position.line}' + f'' + f'{location}' + f'{error.message}' f'' ) - return f""" - - - - - -

AsciiDoc Lint Results

- - - - - - - - {"".join(rows)} -
SeverityRuleMessageLocation
- - - """ \ No newline at end of file + return f""" + + + + AsciiDoc Lint Results + + + +

AsciiDoc Lint Results

+ + + + + + {"".join(rows)} +
LocationMessage
+ +""" \ No newline at end of file diff --git a/asciidoc_linter/rules/base.py b/asciidoc_linter/rules/base.py index 8a1e350..60971db 100755 --- a/asciidoc_linter/rules/base.py +++ b/asciidoc_linter/rules/base.py @@ -46,8 +46,60 @@ class Rule: severity: Severity = Severity.WARNING def check_line(self, line: str, line_number: int, context: List[str]) -> List[Finding]: - """Check a single line for rule violations""" - raise NotImplementedError() + """ + Check a single line for rule violations. + + Args: + line: The current line to check + line_number: The line number in the document (0-based) + context: The complete document as a list of lines + + Returns: + List of Finding objects representing rule violations + """ + findings = [] + + # Get previous and next lines if available + prev_line = context[line_number - 1] if line_number > 0 else None + next_line = context[line_number + 1] if line_number < len(context) - 1 else None + + # Check the line content + line_findings = self.check_line_content(line, line_number) + if line_findings: + findings.extend(line_findings) + + # Check line context if needed + context_findings = self.check_line_context(line, line_number, prev_line, next_line) + if context_findings: + findings.extend(context_findings) + + return findings + + def check_line_content(self, line: str, line_number: int) -> List[Finding]: + """ + Check the content of a single line. + Override this method in concrete rule implementations. + """ + return [] + + def check_line_context(self, line: str, line_number: int, + prev_line: Optional[str], next_line: Optional[str]) -> List[Finding]: + """ + Check a line in context with its surrounding lines. + Override this method in concrete rule implementations. + """ + return [] + + def create_finding(self, line_number: int, message: str, + column: Optional[int] = None, context: Optional[str] = None) -> Finding: + """Helper method to create a Finding object""" + return Finding( + rule_id=self.id, + position=Position(line_number, column), + message=message, + severity=self.severity, + context=context + ) class RuleRegistry: """Registry for all available rules""" diff --git a/asciidoc_linter/rules/base.py.meta b/asciidoc_linter/rules/base.py.meta index d48b74b..9f35c7f 100755 --- a/asciidoc_linter/rules/base.py.meta +++ b/asciidoc_linter/rules/base.py.meta @@ -1 +1 @@ -Base functionality for rules including Position class \ No newline at end of file +Updated base.py with check_line implementation \ No newline at end of file diff --git a/asciidoc_linter/rules/block_rules.py b/asciidoc_linter/rules/block_rules.py old mode 100644 new mode 100755 index 6e111a2..481563e --- a/asciidoc_linter/rules/block_rules.py +++ b/asciidoc_linter/rules/block_rules.py @@ -1,18 +1,14 @@ # block_rules.py - Rules for checking AsciiDoc blocks -from typing import List, Dict +from typing import List, Dict, Any, Union from .base import Rule, Finding, Severity, Position class UnterminatedBlockRule(Rule): """Rule to check for unterminated blocks in AsciiDoc files.""" - id = "BLOCK001" - name = "Unterminated Block Check" - description = "Checks for blocks that are not properly terminated" - severity = Severity.ERROR - def __init__(self): super().__init__() + self.id = "BLOCK001" self.block_markers = { "----": "listing block", "====": "example block", @@ -23,24 +19,27 @@ def __init__(self): "////": "comment block", "++++": "passthrough block", } - self.open_blocks: Dict[str, int] = {} + self.open_blocks = {} + + @property + def description(self) -> str: + return "Checks for blocks that are not properly terminated" - def check_line(self, line: str, line_number: int, context: List[str]) -> List[Finding]: + def check_line(self, line: str, line_num: int, document: List[str]) -> List[Finding]: findings = [] stripped_line = line.strip() - - # Check if line is a block delimiter + if stripped_line in self.block_markers: if stripped_line in self.open_blocks: # This is a closing delimiter del self.open_blocks[stripped_line] else: # This is an opening delimiter - self.open_blocks[stripped_line] = line_number + self.open_blocks[stripped_line] = line_num # Look ahead for matching end delimiter is_terminated = False - for i, next_line in enumerate(context[line_number + 1:], start=line_number + 1): + for i, next_line in enumerate(document[line_num + 1:], start=line_num + 1): if next_line.strip() == stripped_line: is_terminated = True break @@ -49,65 +48,101 @@ def check_line(self, line: str, line_number: int, context: List[str]) -> List[Fi findings.append( Finding( rule_id=self.id, - position=Position(line=line_number + 1), + position=Position(line=line_num + 1), message=f"Unterminated {self.block_markers[stripped_line]} starting", - severity=self.severity, + severity=Severity.ERROR, context=line ) ) return findings + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + self.open_blocks = {} # Reset open blocks + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + for line_num, line in enumerate(lines): + if isinstance(line, str): + findings.extend(self.check_line(line, line_num, lines)) + + return findings + class BlockSpacingRule(Rule): """Rule to check for proper spacing around blocks.""" - id = "BLOCK002" - name = "Block Spacing Check" - description = "Checks for proper blank lines around blocks" - severity = Severity.WARNING - def __init__(self): super().__init__() + self.id = "BLOCK002" self.block_markers = { "----", "====", "****", "....", "____", "|===", "////", "++++" } - self.open_blocks: Dict[str, int] = {} + self.open_blocks = {} + + @property + def description(self) -> str: + return "Checks for proper blank lines around blocks" - def check_line(self, line: str, line_number: int, context: List[str]) -> List[Finding]: + def check_line(self, line: str, line_num: int, document: List[str]) -> List[Finding]: findings = [] stripped_line = line.strip() - + if stripped_line in self.block_markers: if stripped_line in self.open_blocks: # This is a closing delimiter - if line_number + 1 < len(context): - next_line = context[line_number + 1].strip() + if line_num + 1 < len(document): + next_line = document[line_num + 1].strip() if next_line and not next_line.startswith('='): findings.append( Finding( rule_id=self.id, - position=Position(line=line_number + 2), + position=Position(line=line_num + 2), message="Block should be followed by a blank line", - severity=self.severity, - context=context[line_number + 1] + severity=Severity.WARNING, + context=document[line_num + 1] ) ) del self.open_blocks[stripped_line] else: # This is an opening delimiter - if line_number > 0: - prev_line = context[line_number - 1].strip() + if line_num > 0: + prev_line = document[line_num - 1].strip() if prev_line and not prev_line.startswith('='): findings.append( Finding( rule_id=self.id, - position=Position(line=line_number + 1), + position=Position(line=line_num + 1), message="Block should be preceded by a blank line", - severity=self.severity, + severity=Severity.WARNING, context=line ) ) - self.open_blocks[stripped_line] = line_number + self.open_blocks[stripped_line] = line_num + + return findings + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + self.open_blocks = {} # Reset open blocks + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + for line_num, line in enumerate(lines): + if isinstance(line, str): + findings.extend(self.check_line(line, line_num, lines)) return findings \ No newline at end of file diff --git a/asciidoc_linter/rules/block_rules.py.meta b/asciidoc_linter/rules/block_rules.py.meta old mode 100644 new mode 100755 index 5717304..77f0b85 --- a/asciidoc_linter/rules/block_rules.py.meta +++ b/asciidoc_linter/rules/block_rules.py.meta @@ -1 +1 @@ -Updated block rules to use Position instead of line_number \ No newline at end of file +Updated implementation of block rules \ No newline at end of file diff --git a/asciidoc_linter/rules/heading_rules.py b/asciidoc_linter/rules/heading_rules.py index 25c2744..8daa4b4 100755 --- a/asciidoc_linter/rules/heading_rules.py +++ b/asciidoc_linter/rules/heading_rules.py @@ -3,8 +3,9 @@ This module contains rules for checking AsciiDoc heading structure and format. """ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Dict, Any, Union from .base import Rule, Finding, Severity, Position +import re class HeadingFormatRule(Rule): """ @@ -17,59 +18,61 @@ class HeadingFormatRule(Rule): def __init__(self): super().__init__() self.id = "HEAD002" + self.heading_pattern = re.compile(r'^(=+)(\s*)(.*)$') @property def description(self) -> str: return "Ensures proper heading format (spacing and capitalization)" - def check(self, content: str) -> List[Finding]: + def check_line(self, line: str, line_num: int) -> List[Finding]: findings = [] + match = self.heading_pattern.match(line) - for line_num, line in enumerate(content.splitlines(), 1): - # Skip empty lines and non-heading lines - if not line or not line.startswith('='): - continue - - # Skip lines that look like header underlines (====) - if line.strip() == '=' * len(line.strip()): - continue - - # Count leading = characters - level = 0 - for char in line: - if char != '=': - break - level += 1 - - # Get the heading text, handling both cases: - # 1. No space after = (=Title) - # 2. Space after = (= Title) - heading_text = line[level:].strip() + if match: + equals, space, text = match.groups() + level = len(equals) # Check for space after = characters - if level > 0 and (len(line) <= level or line[level] != ' '): + if not space: findings.append(Finding( rule_id=self.id, message=f"Missing space after {'=' * level}", severity=Severity.ERROR, - position=Position(line=line_num), - context=line.strip() + position=Position(line=line_num + 1), + context=line )) # Check if heading starts with lowercase (only if we have text) - if heading_text: + if text: # Split into words and check first word - words = heading_text.split() + words = text.strip().split() if words and words[0][0].islower(): findings.append(Finding( rule_id=self.id, message="Heading should start with uppercase letter", severity=Severity.WARNING, - position=Position(line=line_num), - context=line.strip() + position=Position(line=line_num + 1), + context=line )) return findings + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + for line_num, line in enumerate(lines): + if isinstance(line, str): + findings.extend(self.check_line(line, line_num)) + + return findings class HeadingHierarchyRule(Rule): """ @@ -80,51 +83,50 @@ class HeadingHierarchyRule(Rule): def __init__(self): super().__init__() self.id = "HEAD001" + self.heading_pattern = re.compile(r'^(=+)\s') @property def description(self) -> str: return "Ensures proper heading level incrementation (no skipped levels)" - def check(self, content: str) -> List[Finding]: + def get_heading_levels(self, document: List[str]) -> List[Tuple[int, int, str]]: + """Extract heading levels with line numbers.""" + headings = [] + for line_num, line in enumerate(document): + if isinstance(line, str): + match = self.heading_pattern.match(line) + if match: + level = len(match.group(1)) + headings.append((level, line_num, line)) + return headings + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: findings = [] - current_level = 0 - last_heading_line = 0 - for line_num, line in enumerate(content.splitlines(), 1): - # Skip empty lines and non-heading lines - if not line or not line.startswith('='): - continue - - # Count the number of '=' at the start of the line - level = 0 - for char in line: - if char != '=': - break - level += 1 - - # Skip lines that look like header underlines (====) - if level == len(line.strip()): - continue - - # First heading can be any level - if current_level == 0: - current_level = level - last_heading_line = line_num - continue - - # Check if we're skipping levels + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + headings = self.get_heading_levels(lines) + if not headings: + return findings + + current_level = headings[0][0] + for level, line_num, line in headings[1:]: if level > current_level + 1: findings.append(Finding( rule_id=self.id, message=f"Heading level skipped: found h{level} after h{current_level}", severity=Severity.ERROR, - position=Position(line=line_num), - context=line.strip() + position=Position(line=line_num + 1), + context=line )) - current_level = level - last_heading_line = line_num - + return findings class MultipleTopLevelHeadingsRule(Rule): @@ -136,46 +138,35 @@ class MultipleTopLevelHeadingsRule(Rule): def __init__(self): super().__init__() self.id = "HEAD003" + self.heading_pattern = re.compile(r'^=\s') @property def description(self) -> str: return "Ensures document has only one top-level heading" - def check(self, content: str) -> List[Finding]: + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: findings = [] - first_top_level: Optional[Tuple[int, str]] = None # (line_number, heading_text) + first_top_level: Optional[Tuple[int, str]] = None - for line_num, line in enumerate(content.splitlines(), 1): - # Skip empty lines and non-heading lines - if not line or not line.startswith('='): - continue - - # Skip lines that look like header underlines (====) - if line.strip() == '=' * len(line.strip()): - continue - - # Count leading = characters - level = 0 - for char in line: - if char != '=': - break - level += 1 - - # Check for top-level headings (single =) - if level == 1: - heading_text = line[level:].strip() - + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + for line_num, line in enumerate(lines): + if isinstance(line, str) and self.heading_pattern.match(line): if first_top_level is None: - # Remember first top-level heading - first_top_level = (line_num, heading_text) + first_top_level = (line_num + 1, line.strip()) else: - # Found another top-level heading findings.append(Finding( rule_id=self.id, message=f"Multiple top-level headings found. First heading at line {first_top_level[0]}: '{first_top_level[1]}'", severity=Severity.ERROR, - position=Position(line=line_num), - context=line.strip() + position=Position(line=line_num + 1), + context=line )) return findings \ No newline at end of file diff --git a/asciidoc_linter/rules/heading_rules.py.meta b/asciidoc_linter/rules/heading_rules.py.meta index 2a8542e..dd80e11 100755 --- a/asciidoc_linter/rules/heading_rules.py.meta +++ b/asciidoc_linter/rules/heading_rules.py.meta @@ -1 +1 @@ -Implementation of heading rules with renamed HeadingHierarchyRule \ No newline at end of file +Updated implementation of heading rules with raw text support \ No newline at end of file diff --git a/asciidoc_linter/rules/image_rules.py b/asciidoc_linter/rules/image_rules.py old mode 100644 new mode 100755 index ca3fb62..35c12fb --- a/asciidoc_linter/rules/image_rules.py +++ b/asciidoc_linter/rules/image_rules.py @@ -2,7 +2,7 @@ import os import re -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional, Tuple, Any, Union from .base import Rule, Finding, Severity, Position class ImageAttributesRule(Rule): @@ -18,6 +18,13 @@ def __init__(self): self.current_line = 0 self.current_context = "" + def check(self, document: List[Any]) -> List[Finding]: + """Check the entire document for image-related issues.""" + findings = [] + for i, line in enumerate(document): + findings.extend(self.check_line(line, i, document)) + return findings + def _check_image_path(self, path: str) -> List[Finding]: """Check if the image file exists and is accessible.""" findings = [] @@ -122,14 +129,21 @@ def _parse_attributes(self, attr_string: str) -> Dict[str, str]: return attributes - def check_line(self, line: str, line_number: int, context: List[str]) -> List[Finding]: + def check_line(self, line: Union[str, Any], line_number: int, context: List[Any]) -> List[Finding]: """Check a line for image-related issues.""" findings = [] self.current_line = line_number - self.current_context = line + + # Handle Header objects and other non-string types + if hasattr(line, 'content'): + line_content = line.content + else: + line_content = str(line) + + self.current_context = line_content # Check for block images - block_image_match = re.match(r'image::([^[]+)(?:\[(.*)\])?', line.strip()) + block_image_match = re.match(r'image::([^[]+)(?:\[(.*)\])?', line_content.strip()) if block_image_match: path = block_image_match.group(1) attributes = self._parse_attributes(block_image_match.group(2) or '') @@ -139,7 +153,7 @@ def check_line(self, line: str, line_number: int, context: List[str]) -> List[Fi return findings # Check for inline images - for inline_match in re.finditer(r'image:([^[]+)(?:\[(.*?)\])?', line): + for inline_match in re.finditer(r'image:([^[]+)(?:\[(.*?)\])?', line_content): path = inline_match.group(1) attributes = self._parse_attributes(inline_match.group(2) or '') diff --git a/asciidoc_linter/rules/image_rules.py.meta b/asciidoc_linter/rules/image_rules.py.meta old mode 100644 new mode 100755 index cc016d4..80bcc91 --- a/asciidoc_linter/rules/image_rules.py.meta +++ b/asciidoc_linter/rules/image_rules.py.meta @@ -1 +1 @@ -Updated image rules with corrected file existence checking \ No newline at end of file +Updated image rules to handle Header objects \ No newline at end of file diff --git a/asciidoc_linter/rules/table_rules.py b/asciidoc_linter/rules/table_rules.py new file mode 100755 index 0000000..6980026 --- /dev/null +++ b/asciidoc_linter/rules/table_rules.py @@ -0,0 +1,280 @@ +# table_rules.py - Implementation of table rules +""" +This module contains rules for checking AsciiDoc table structure and format. +""" + +from typing import List, Dict, Any, Union, Optional, Tuple +from .base import Rule, Finding, Severity, Position +import re + +class TableFormatRule(Rule): + """ + TABLE001: Check table formatting. + Ensures that tables are consistently formatted: + - Column separators are properly aligned + - Header row is properly marked + - Cells are properly aligned + """ + + def __init__(self): + super().__init__() + self.id = "TABLE001" + self.cell_pattern = re.compile(r'\|([^|]*)') + + @property + def description(self) -> str: + return "Ensures consistent table formatting (alignment and structure)" + + def extract_table_lines(self, lines: List[str]) -> List[List[Tuple[int, str]]]: + """Extract tables from document lines. + Returns a list of tables, where each table is a list of (line_number, line) tuples. + """ + tables = [] + current_table = [] + in_table = False + + for line_num, line in enumerate(lines): + if not isinstance(line, str): + continue + + stripped = line.strip() + if stripped == "|===": + if in_table: + current_table.append((line_num, line)) + tables.append(current_table) + current_table = [] + in_table = False + else: + in_table = True + current_table = [(line_num, line)] + elif in_table: + current_table.append((line_num, line)) + + return tables + + def check_column_alignment(self, table_lines: List[Tuple[int, str]]) -> List[Finding]: + """Check if columns are consistently aligned within a table.""" + findings = [] + cell_positions = [] # List of lists of cell positions for each row + + for line_num, line in table_lines[1:-1]: # Skip table markers + if not line.strip(): # Skip empty lines + continue + + matches = list(self.cell_pattern.finditer(line)) + if matches: + positions = [m.start() for m in matches] + if cell_positions and positions != cell_positions[0]: + findings.append(Finding( + rule_id=self.id, + message="Column alignment is inconsistent with previous rows", + severity=Severity.WARNING, + position=Position(line=line_num + 1), + context=line + )) + break + cell_positions.append(positions) + + return findings + + def check_header_separator(self, table_lines: List[Tuple[int, str]]) -> List[Finding]: + """Check if header is properly separated from content.""" + findings = [] + + # Find header row + header_line = None + for i, (line_num, line) in enumerate(table_lines[1:-1], 1): # Skip table markers + if "|" in line: + header_line = i + break + + if header_line is not None: + # Check for empty line after header + next_line = header_line + 1 + if next_line < len(table_lines) - 1: # Ensure we're not at the end + if table_lines[next_line][1].strip(): # Line after header should be empty + findings.append(Finding( + rule_id=self.id, + message="Header row should be followed by an empty line", + severity=Severity.WARNING, + position=Position(line=table_lines[next_line][0] + 1), + context=table_lines[next_line][1] + )) + + return findings + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + # Extract tables + tables = self.extract_table_lines(lines) + + # Check each table + for table in tables: + findings.extend(self.check_column_alignment(table)) + findings.extend(self.check_header_separator(table)) + + return findings + +class TableStructureRule(Rule): + """ + TABLE002: Check table structure. + Ensures that tables have consistent structure: + - All rows have the same number of columns + - No empty tables + - No missing cells + """ + + def __init__(self): + super().__init__() + self.id = "TABLE002" + self.cell_pattern = re.compile(r'\|([^|]*)') + + @property + def description(self) -> str: + return "Ensures consistent table structure (column counts and completeness)" + + def count_columns(self, line: str) -> int: + """Count the number of columns in a table row.""" + return len(self.cell_pattern.findall(line)) + + def check_table_structure(self, table_lines: List[Tuple[int, str]]) -> List[Finding]: + """Check table structure for consistency.""" + findings = [] + column_count = None + content_lines = 0 + + for line_num, line in table_lines[1:-1]: # Skip table markers + stripped = line.strip() + if not stripped: # Skip empty lines + continue + + if "|" in stripped: + content_lines += 1 + current_columns = self.count_columns(stripped) + + if column_count is None: + column_count = current_columns + elif current_columns != column_count: + findings.append(Finding( + rule_id=self.id, + message=f"Inconsistent column count. Expected {column_count}, found {current_columns}", + severity=Severity.ERROR, + position=Position(line=line_num + 1), + context=line + )) + + if content_lines == 0: + findings.append(Finding( + rule_id=self.id, + message="Empty table", + severity=Severity.WARNING, + position=Position(line=table_lines[0][0] + 1), + context=table_lines[0][1] + )) + + return findings + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + # Extract tables + tables = TableFormatRule.extract_table_lines(self, lines) + + # Check each table + for table in tables: + findings.extend(self.check_table_structure(table)) + + return findings + +class TableContentRule(Rule): + """ + TABLE003: Check table cell content. + Ensures that table cells don't contain complex content without proper declarations: + - Lists in cells + - Nested tables + """ + + def __init__(self): + super().__init__() + self.id = "TABLE003" + self.cell_pattern = re.compile(r'([a-z]?)\|([^|]*)') + self.list_pattern = re.compile(r'^\s*[\*\-]') + + @property + def description(self) -> str: + return "Checks for proper declaration of complex content in table cells" + + def check_cell_content(self, cell_match: re.Match, line_num: int, context: str) -> Optional[Finding]: + """Check a single cell for complex content. Returns a finding or None.""" + prefix = cell_match.group(1) + content = cell_match.group(2).strip() + + # Check for lists - only check if content starts with a list marker + if content and self.list_pattern.match(content): + if prefix not in ['a', 'l']: + return Finding( + rule_id=self.id, + message="List in table cell requires 'a|' or 'l|' declaration", + severity=Severity.WARNING, + position=Position(line=line_num + 1), + context=context + ) + + + return None + + def check(self, document: Union[Dict[str, Any], List[Any]]) -> List[Finding]: + findings = [] + + # Convert document to lines if it's not already + if isinstance(document, dict): + lines = document.get('content', '').splitlines() + elif isinstance(document, str): + lines = document.splitlines() + else: + lines = document + + # Extract tables + tables = TableFormatRule.extract_table_lines(self, lines) + + # Check each table + for table in tables: + seen_list_cells = set() # Track cells with list findings to avoid duplicates + + for line_num, line in table[1:-1]: # Skip table markers + if not line.strip(): # Skip empty lines + continue + + # Extract and check cells + for cell_match in self.cell_pattern.finditer(line): + content = cell_match.group(2).strip() + cell_pos = cell_match.start() + + # Skip if we've already reported a list finding for this cell + if self.list_pattern.match(content) and cell_pos in seen_list_cells: + continue + + finding = self.check_cell_content(cell_match, line_num, line) + if finding: + findings.append(finding) + if self.list_pattern.match(content): + seen_list_cells.add(cell_pos) + + return findings \ No newline at end of file diff --git a/asciidoc_linter/rules/table_rules.py.meta b/asciidoc_linter/rules/table_rules.py.meta new file mode 100755 index 0000000..3951878 --- /dev/null +++ b/asciidoc_linter/rules/table_rules.py.meta @@ -0,0 +1 @@ +Updated table rules with improved error message for code block declaration \ No newline at end of file diff --git a/asciidoc_linter/rules/whitespace_rules.py b/asciidoc_linter/rules/whitespace_rules.py old mode 100644 new mode 100755 index 837dbc4..a95968e --- a/asciidoc_linter/rules/whitespace_rules.py +++ b/asciidoc_linter/rules/whitespace_rules.py @@ -1,6 +1,6 @@ # whitespace_rules.py - Rules for checking whitespace in AsciiDoc files -from typing import List +from typing import List, Union from .base import Rule, Finding, Severity, Position class WhitespaceRule(Rule): @@ -15,11 +15,25 @@ def __init__(self): super().__init__() self.consecutive_empty_lines = 0 - def check_line(self, line: str, line_number: int, context: List[str]) -> List[Finding]: + def check(self, document: List[Union[str, object]]) -> List[Finding]: + """Check the entire document for whitespace issues.""" findings = [] + for line_number, line in enumerate(document): + findings.extend(self.check_line(line, line_number, document)) + return findings + + def get_line_content(self, line: Union[str, object]) -> str: + """Extract the content from a line object or return the line if it's a string.""" + if hasattr(line, 'content'): + return line.content + return str(line) + + def check_line(self, line: Union[str, object], line_number: int, context: List[Union[str, object]]) -> List[Finding]: + findings = [] + line_content = self.get_line_content(line) # Check for multiple consecutive empty lines - if not line.strip(): + if not line_content.strip(): self.consecutive_empty_lines += 1 if self.consecutive_empty_lines > 2: findings.append( @@ -28,17 +42,17 @@ def check_line(self, line: str, line_number: int, context: List[str]) -> List[Fi position=Position(line=line_number + 1), message="Too many consecutive empty lines", severity=self.severity, - context=line + context=line_content ) ) else: self.consecutive_empty_lines = 0 # Check for proper list marker spacing - if line.lstrip().startswith(('*', '-', '.')): - marker = line.lstrip()[0] - indent = len(line) - len(line.lstrip()) - content = line.lstrip()[1:] + if line_content.lstrip().startswith(('*', '-', '.')): + marker = line_content.lstrip()[0] + indent = len(line_content) - len(line_content.lstrip()) + content = line_content.lstrip()[1:] if not content.startswith(' '): findings.append( @@ -47,91 +61,97 @@ def check_line(self, line: str, line_number: int, context: List[str]) -> List[Fi position=Position(line=line_number + 1), message=f"Missing space after the marker '{marker}'", severity=self.severity, - context=line + context=line_content ) ) # Check for trailing whitespace - if line.rstrip() != line: + if line_content.rstrip() != line_content: findings.append( Finding( rule_id=self.id, position=Position(line=line_number + 1), message="Line contains trailing whitespace", severity=self.severity, - context=line + context=line_content ) ) # Check for tabs - if '\t' in line: + if '\t' in line_content: findings.append( Finding( rule_id=self.id, position=Position(line=line_number + 1), message="Line contains tabs (use spaces instead)", severity=self.severity, - context=line + context=line_content ) ) # Check for proper section title spacing - if line.startswith('='): + if line_content.startswith('='): # Count leading = characters level = 0 - for char in line: + for char in line_content: if char != '=': break level += 1 # Check spacing after = characters - if len(line) > level and line[level] != ' ': + if len(line_content) > level and line_content[level] != ' ': findings.append( Finding( rule_id=self.id, position=Position(line=line_number + 1), message=f"Missing space after {'=' * level}", severity=self.severity, - context=line + context=line_content ) ) # Check for blank line before section title (except for first line) - if line_number > 0 and context[line_number - 1].strip(): - findings.append( - Finding( - rule_id=self.id, - position=Position(line=line_number + 1), - message="Section title should be preceded by a blank line", - severity=self.severity, - context=line + if line_number > 0: + prev_content = self.get_line_content(context[line_number - 1]) + if prev_content.strip(): + findings.append( + Finding( + rule_id=self.id, + position=Position(line=line_number + 1), + message="Section title should be preceded by a blank line", + severity=self.severity, + context=line_content + ) ) - ) # Check for blank line after section title (except for last line) - if line_number < len(context) - 1 and context[line_number + 1].strip(): - findings.append( - Finding( - rule_id=self.id, - position=Position(line=line_number + 1), - message="Section title should be followed by a blank line", - severity=self.severity, - context=line + if line_number < len(context) - 1: + next_content = self.get_line_content(context[line_number + 1]) + if next_content.strip(): + findings.append( + Finding( + rule_id=self.id, + position=Position(line=line_number + 1), + message="Section title should be followed by a blank line", + severity=self.severity, + context=line_content + ) ) - ) # Check for proper admonition block spacing admonition_markers = ['NOTE:', 'TIP:', 'IMPORTANT:', 'WARNING:', 'CAUTION:'] - if any(line.strip().startswith(marker) for marker in admonition_markers): - if line_number > 0 and context[line_number - 1].strip(): - findings.append( - Finding( - rule_id=self.id, - position=Position(line=line_number + 1), - message="Admonition block should be preceded by a blank line", - severity=self.severity, - context=line + if any(line_content.strip().startswith(marker) for marker in admonition_markers): + if line_number > 0: + prev_content = self.get_line_content(context[line_number - 1]) + if prev_content.strip(): + findings.append( + Finding( + rule_id=self.id, + position=Position(line=line_number + 1), + message="Admonition block should be preceded by a blank line", + severity=self.severity, + context=line_content + ) ) - ) return findings \ No newline at end of file diff --git a/asciidoc_linter/rules/whitespace_rules.py.meta b/asciidoc_linter/rules/whitespace_rules.py.meta old mode 100644 new mode 100755 index 0af67ec..e2c4af9 --- a/asciidoc_linter/rules/whitespace_rules.py.meta +++ b/asciidoc_linter/rules/whitespace_rules.py.meta @@ -1 +1 @@ -Updated whitespace rules with corrected error messages and additional section title checks \ No newline at end of file +Updated whitespace rules to handle Header objects \ No newline at end of file diff --git a/coverage.xml b/coverage.xml old mode 100644 new mode 100755 diff --git a/debug_env.py b/debug_env.py old mode 100644 new mode 100755 diff --git a/debug_env.py.meta b/debug_env.py.meta old mode 100644 new mode 100755 diff --git a/docs/implementation_plan.adoc b/docs/implementation_plan.adoc new file mode 100644 index 0000000..f77889e --- /dev/null +++ b/docs/implementation_plan.adoc @@ -0,0 +1,161 @@ +// implementation_plan.adoc - Implementation plan for AsciiDoc Linter += AsciiDoc Linter Implementation Plan +:toc: +:toc-placement: preamble +:sectanchors: +:sectlinks: + +== Current Status Analysis (Updated December 2023) + +=== Test Status +All tests are currently passing, including: + +* Heading Rules Tests +** Hierarchy checking +** Format validation +** Multiple top-level detection + +* Block Rules Tests +** Unterminated block detection +** Block spacing validation + +* Image Rules Tests +** Attribute validation +** File reference checking +** Alt text verification + +* Table Rules Tests +** Content validation +** Format checking +** Structure verification + +* Whitespace Rules Tests +** Line spacing +** List marker formatting +** Tab detection + +=== Test Coverage Status +Test coverage tools (coverage.py and pytest-html) need attention: + +* coverage report command not working properly +* HTML test report generation failing +* Need to fix configuration for both tools + +== Implementation Plan + +=== Phase 1: Tool Infrastructure (1-2 days) + +==== Step 1: Test Coverage Tools (Priority: High) +* Fix coverage.py integration +** Update configuration +** Ensure proper source code detection +** Fix report generation + +==== Step 2: Test Reporting (Priority: High) +* Fix HTML test report generation +** Update pytest configuration +** Fix command line arguments handling +** Add proper output directory handling + +=== Phase 2: New Features (3-4 days) + +==== Step 1: Table Validation Enhancement (Priority: High) +* Add new table rules: +** Cell content formatting validation +** Complex content structure checking +** Table caption validation +* Implementation tasks: +** Create new TableContentFormatRule +** Add tests for complex table structures +** Update documentation + +==== Step 2: Link Checking (Priority: Medium) +* Implement link validation: +** Internal cross-reference checking +** External URL validation +** Anchor existence verification +* Implementation tasks: +** Create LinkValidationRule class +** Add URL checking functionality +** Add tests for various link types + +=== Phase 3: Integration Features (4-5 days) + +==== Step 1: VS Code Extension (Priority: Medium) +* Create basic VS Code extension +** Real-time linting +** Problem highlighting +** Quick fixes for common issues +* Implementation tasks: +** Set up extension project +** Implement language server protocol +** Add configuration options + +==== Step 2: Git Integration (Priority: Low) +* Add Git hooks support: +** Pre-commit hook implementation +** Configuration options +** Documentation +* Implementation tasks: +** Create hook scripts +** Add configuration handling +** Write installation guide + +== Implementation Schedule + +[cols="1,2,1,1"] +|=== +|Phase |Task |Effort |Priority + +|1 +|Test Coverage Tools +|1 day +|High + +|1 +|Test Reporting +|1 day +|High + +|2 +|Table Validation +|2 days +|High + +|2 +|Link Checking +|2 days +|Medium + +|3 +|VS Code Extension +|3 days +|Medium + +|3 +|Git Integration +|2 days +|Low +|=== + +== Next Steps + +1. Fix test coverage tools +2. Implement enhanced table validation +3. Add link checking functionality +4. Start VS Code extension development + +== Success Criteria + +* All test tools working properly +* Test coverage >90% for all modules +* Documentation up to date +* New features fully tested +* IDE integration working reliably + +== Notes + +* All previously reported test failures have been fixed +* ImageAttributesRule and TableContentRule are now working correctly +* Focus should be on new features and tool infrastructure +* Consider adding performance benchmarks for large documents \ No newline at end of file diff --git a/docs/implementation_plan.adoc.meta b/docs/implementation_plan.adoc.meta new file mode 100644 index 0000000..edaa82a --- /dev/null +++ b/docs/implementation_plan.adoc.meta @@ -0,0 +1 @@ +Updated implementation plan reflecting current status and next steps \ No newline at end of file diff --git a/docs/manual/testing.adoc b/docs/manual/testing.adoc index ccdaf22..28b7b30 100755 --- a/docs/manual/testing.adoc +++ b/docs/manual/testing.adoc @@ -1,39 +1,63 @@ // testing.adoc - Testing guide -= Testing Guide += Testing Strategy and Guide +:toc: +:toc-title: Table of Contents +:sectnums: -== Overview +== Test Strategy -The AsciiDoc Linter uses Python's unittest framework for testing. Tests are organized by rule type and functionality. +=== Goals and Objectives -== Running Tests +The testing strategy for the AsciiDoc Linter aims to: -=== Running All Tests +* Ensure reliable detection of AsciiDoc formatting issues +* Prevent false positives that could frustrate users +* Maintain high code quality through comprehensive testing +* Enable safe refactoring through good test coverage +* Support rapid development through automated testing -[source,bash] ----- -# From project root -python run_tests.py +=== Test Levels -# With coverage report -coverage run -m unittest discover -coverage report ----- +==== Unit Tests +* Test individual rules in isolation +* Verify rule logic and error detection +* Cover edge cases and special scenarios +* Test configuration options -=== Running Specific Tests +==== Integration Tests +* Test interaction between parser and rules +* Verify correct document processing +* Test CLI interface and options +* Test reporter output formats -[source,bash] ----- -# Run tests for heading rules -python -m unittest tests/rules/test_heading_rules.py +==== System Tests +* End-to-end testing of the linter +* Test with real AsciiDoc documents +* Verify correct error reporting +* Test performance with large documents -# Run a specific test class -python -m unittest tests.rules.test_heading_rules.TestHeadingFormatRule +=== Test Coverage Goals -# Run a specific test method -python -m unittest tests.rules.test_heading_rules.TestHeadingFormatRule.test_valid_format ----- +[cols="2,1,2"] +|=== +|Component |Target Coverage |Current Coverage + +|Core (parser, linter) |90% |0% +|Rules |95% |88% +|CLI |80% |0% +|Reporter |85% |0% +|Overall |90% |61% +|=== -== Test Structure +=== Quality Metrics + +* Line Coverage: Minimum 90% +* Branch Coverage: Minimum 85% +* Mutation Score: Minimum 80% +* Test Success Rate: 100% +* No known bugs in production + +== Test Implementation === Test Organization @@ -41,98 +65,199 @@ python -m unittest tests.rules.test_heading_rules.TestHeadingFormatRule.test_val ---- tests/ ├── __init__.py -├── rules/ +├── rules/ # Rule-specific tests │ ├── __init__.py │ ├── test_heading_rules.py -│ └── test_block_rules.py -├── test_cli.py -└── test_parser.py +│ ├── test_block_rules.py +│ ├── test_image_rules.py +│ └── test_whitespace_rules.py +├── integration/ # Integration tests +│ ├── __init__.py +│ ├── test_parser_rules.py +│ └── test_cli_reporter.py +├── system/ # System tests +│ ├── __init__.py +│ ├── test_large_docs.py +│ └── test_real_projects.py +├── test_cli.py # CLI tests +├── test_parser.py # Parser tests +├── test_reporter.py # Reporter tests +└── test_linter.py # Core linter tests ---- -=== Test Classes +=== Test Patterns -Each rule has its own test class: +==== Rule Tests [source,python] ---- -class TestHeadingFormatRule(unittest.TestCase): - def setUp(self): - self.rule = HeadingFormatRule() +def test_rule_pattern(self): + # Given: Setup test data and context + content = "test content" + rule = TestRule(config) - def test_valid_format(self): - content = """ - = Valid Heading - == Another Valid - """ - findings = self.rule.check(content) - self.assertEqual(len(findings), 0) + # When: Execute the rule + findings = rule.check(content) + + # Then: Verify results + assert_findings(findings) ---- -== Writing Tests - -=== Test Guidelines - -* Test both valid and invalid cases -* Include edge cases -* Test error messages -* Test severity levels -* Test rule configurations - -=== Example Test Pattern - -1. Arrange: Set up test data -2. Act: Execute the code -3. Assert: Verify results +==== Integration Tests [source,python] ---- -def test_invalid_format(self): - # Arrange - content = "=invalid heading" +def test_integration_pattern(self): + # Given: Setup test environment + doc = create_test_document() + linter = setup_linter() - # Act - findings = self.rule.check(content) + # When: Process document + results = linter.process(doc) - # Assert - self.assertEqual(len(findings), 2) - self.assertEqual(findings[0].severity, Severity.ERROR) + # Then: Verify complete workflow + verify_results(results) ---- -== Test Data +=== Test Data Management + +==== Test Documents +* Maintain a collection of test documents +* Include both valid and invalid examples +* Document the purpose of each test file +* Version control test data + +==== Test Fixtures +* Use pytest fixtures for common setup +* Share test data between related tests +* Clean up test environment after each test +* Mock external dependencies + +== Running Tests -=== Sample Documents +=== Local Development -* Create realistic test documents -* Cover various scenarios -* Include complex cases -* Document test case purpose +[source,bash] +---- +# Run all tests +python run_tests.py -=== Test Fixtures +# Run with coverage +coverage run -m pytest +coverage report +coverage html -* Use setUp and tearDown -* Share common test data -* Clean up after tests +# Run specific test categories +pytest tests/rules/ +pytest tests/integration/ +pytest tests/system/ +---- -== Continuous Integration +=== Continuous Integration -=== GitHub Actions +==== GitHub Actions Workflow [source,yaml] ---- -name: Tests +name: Test Suite on: [push, pull_request] jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, "3.10"] steps: - uses: actions/checkout@v2 - - name: Run Tests - run: python run_tests.py + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + run: | + coverage run -m pytest + coverage report + coverage xml + - name: Upload coverage + uses: codecov/codecov-action@v2 +---- + +== Test Maintenance + +=== Regular Activities + +* Review test coverage reports weekly +* Update tests for new features +* Refactor tests when needed +* Review test performance +* Update test documentation + +=== Quality Checks + +* Run mutation testing monthly +* Review test maintainability +* Check for flaky tests +* Verify test isolation + +== Appendix + +=== Test Templates + +==== Unit Test Template + +[source,python] ---- +class TestRuleName(unittest.TestCase): + def setUp(self): + """Setup test environment""" + self.rule = RuleUnderTest() + + def test_valid_case(self): + """Test with valid input""" + # Given + content = "valid content" + + # When + findings = self.rule.check(content) + + # Then + self.assertEqual(len(findings), 0) + + def test_invalid_case(self): + """Test with invalid input""" + # Given + content = "invalid content" + + # When + findings = self.rule.check(content) + + # Then + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].severity, Severity.ERROR) +---- + +=== Test Checklists + +==== New Feature Checklist +* [ ] Unit tests written +* [ ] Integration tests updated +* [ ] System tests verified +* [ ] Coverage goals met +* [ ] Documentation updated + +==== Test Review Checklist +* [ ] Tests follow patterns +* [ ] Coverage adequate +* [ ] Edge cases covered +* [ ] Error cases tested +* [ ] Documentation clear -=== Coverage Requirements +== References -* Aim for 90%+ coverage -* Cover all code paths -* Include error conditions -* Test edge cases \ No newline at end of file +* link:https://docs.pytest.org/[Pytest Documentation] +* link:https://coverage.readthedocs.io/[Coverage.py Documentation] +* link:https://github.com/marketplace/actions/codecov[Codecov GitHub Action] \ No newline at end of file diff --git a/docs/manual/testing.adoc.meta b/docs/manual/testing.adoc.meta index b7f4630..87fe5b9 100755 --- a/docs/manual/testing.adoc.meta +++ b/docs/manual/testing.adoc.meta @@ -1 +1 @@ -Testing guide \ No newline at end of file +Updated testing documentation with comprehensive test strategy \ No newline at end of file diff --git a/old_readme.txt b/old_readme.txt old mode 100644 new mode 100755 diff --git a/pytest.ini b/pytest.ini old mode 100644 new mode 100755 diff --git a/pytest.ini.meta b/pytest.ini.meta old mode 100644 new mode 100755 diff --git a/run_tests_html.py.meta b/run_tests_html.py.meta old mode 100644 new mode 100755 diff --git a/setup_test_environment.sh.meta b/setup_test_environment.sh.meta old mode 100644 new mode 100755 diff --git a/tests/rules/test.adoc b/tests/rules/test.adoc new file mode 100644 index 0000000..faa8915 --- /dev/null +++ b/tests/rules/test.adoc @@ -0,0 +1,5 @@ + "|===", + "|Column 1 |Column 2", + "", + "|----\ncode block\n---- |Simple text", # Undeclared code block + "|===", \ No newline at end of file diff --git a/tests/rules/test_block_rules.py b/tests/rules/test_block_rules.py old mode 100644 new mode 100755 diff --git a/tests/rules/test_block_rules.py.meta b/tests/rules/test_block_rules.py.meta old mode 100644 new mode 100755 diff --git a/tests/rules/test_image_rules.py b/tests/rules/test_image_rules.py old mode 100644 new mode 100755 diff --git a/tests/rules/test_image_rules.py.meta b/tests/rules/test_image_rules.py.meta old mode 100644 new mode 100755 diff --git a/tests/rules/test_table_rules.py b/tests/rules/test_table_rules.py new file mode 100755 index 0000000..4ca3fc3 --- /dev/null +++ b/tests/rules/test_table_rules.py @@ -0,0 +1,250 @@ +# test_table_rules.py - Tests for table rules in BDD style +""" +Tests for all table-related rules including: +- TableFormatRule: Ensures consistent table formatting +- TableStructureRule: Validates table structure and column counts +- TableContentRule: Checks for complex content in cells +""" + +import unittest +from asciidoc_linter.rules.table_rules import TableFormatRule, TableStructureRule, TableContentRule + +class TestTableFormatRule(unittest.TestCase): + """Tests for TableFormatRule. + This rule ensures that tables are consistently formatted: + - Column separators are properly aligned + - Header row is properly marked + - Cells are properly aligned + """ + + def setUp(self): + """ + Given a TableFormatRule instance + """ + self.rule = TableFormatRule() + + def test_valid_table(self): + """ + Given a properly formatted table + When the table format rule is checked + Then no findings should be reported + """ + content = [ + "|===", + "|Column 1 |Column 2 |Column 3", + "", + "|Cell 1.1 |Cell 1.2 |Cell 1.3", + "|Cell 2.1 |Cell 2.2 |Cell 2.3", + "|===" + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 0, + "Well-formatted table should not produce findings" + ) + + def test_misaligned_columns(self): + """ + Given a table with misaligned columns + When the table format rule is checked + Then one finding should be reported + And the finding should mention column alignment + """ + content = [ + "|===", + "|Column 1|Column 2 |Column 3", + "", + "|Cell 1.1 |Cell 1.2|Cell 1.3", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 1, + "Misaligned columns should produce one finding" + ) + self.assertTrue( + "alignment" in findings[0].message.lower(), + "Finding should mention column alignment" + ) + + def test_missing_header_separator(self): + """ + Given a table with missing header separator + When the table format rule is checked + Then one finding should be reported + And the finding should mention header separator + """ + content = [ + "|===", + "|Column 1 |Column 2 |Column 3", + "|Cell 1.1 |Cell 1.2 |Cell 1.3", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 1, + "Missing header separator should produce one finding" + ) + self.assertTrue( + "header" in findings[0].message.lower(), + "Finding should mention header separator" + ) + +class TestTableStructureRule(unittest.TestCase): + """Tests for TableStructureRule. + This rule ensures that tables have consistent structure: + - All rows have the same number of columns + - No empty tables + - No missing cells + """ + + def setUp(self): + """ + Given a TableStructureRule instance + """ + self.rule = TableStructureRule() + + def test_consistent_columns(self): + """ + Given a table with consistent column count + When the table structure rule is checked + Then no findings should be reported + """ + content = [ + "|===", + "|Column 1 |Column 2 |Column 3", + "", + "|Cell 1.1 |Cell 1.2 |Cell 1.3", + "|Cell 2.1 |Cell 2.2 |Cell 2.3", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 0, + "Table with consistent columns should not produce findings" + ) + + def test_inconsistent_columns(self): + """ + Given a table with inconsistent column count + When the table structure rule is checked + Then one finding should be reported + And the finding should mention column count + """ + content = [ + "|===", + "|Column 1 |Column 2 |Column 3", + "", + "|Cell 1.1 |Cell 1.2", # Missing one column + "|Cell 2.1 |Cell 2.2 |Cell 2.3", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 1, + "Inconsistent column count should produce one finding" + ) + self.assertTrue( + "column" in findings[0].message.lower(), + "Finding should mention column count" + ) + + def test_empty_table(self): + """ + Given an empty table + When the table structure rule is checked + Then one finding should be reported + And the finding should mention empty table + """ + content = [ + "|===", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 1, + "Empty table should produce one finding" + ) + self.assertTrue( + "empty" in findings[0].message.lower(), + "Finding should mention empty table" + ) + +class TestTableContentRule(unittest.TestCase): + """Tests for TableContentRule. + This rule ensures that table cells don't contain complex content + without proper declarations: + - Lists in cells + - Code blocks in cells + - Nested tables + """ + + def setUp(self): + """ + Given a TableContentRule instance + """ + self.rule = TableContentRule() + + def test_simple_content(self): + """ + Given a table with simple cell content + When the table content rule is checked + Then no findings should be reported + """ + content = [ + "|===", + "|Column 1 |Column 2", + "", + "|Simple text |More text", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 0, + "Simple cell content should not produce findings" + ) + + def test_undeclared_list(self): + """ + Given a table with an undeclared list in a cell + When the table content rule is checked + Then one finding should be reported + And the finding should mention list declaration + """ + content = [ + "|===", + "|Column 1 |Column 2", + "", + "|* List item 1 |Simple text", # Undeclared list + "|* List item 2 |", + "|===", + ] + + findings = self.rule.check(content) + + self.assertEqual( + len(findings), 1, + "Undeclared list in cell should produce one finding" + ) + self.assertTrue( + "list" in findings[0].message.lower(), + "Finding should mention list declaration" + ) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/rules/test_table_rules.py.meta b/tests/rules/test_table_rules.py.meta new file mode 100755 index 0000000..001fd48 --- /dev/null +++ b/tests/rules/test_table_rules.py.meta @@ -0,0 +1 @@ +Updated test file with debug output \ No newline at end of file diff --git a/tests/rules/test_whitespace_rules.py b/tests/rules/test_whitespace_rules.py old mode 100644 new mode 100755 diff --git a/tests/rules/test_whitespace_rules.py.meta b/tests/rules/test_whitespace_rules.py.meta old mode 100644 new mode 100755 diff --git a/tests/test_linter.py b/tests/test_linter.py new file mode 100755 index 0000000..7c0e71a --- /dev/null +++ b/tests/test_linter.py @@ -0,0 +1,162 @@ +# test_linter.py - Tests for the main linter module +""" +Tests for the main linter module (linter.py) +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch + +from asciidoc_linter.linter import AsciiDocLinter +from asciidoc_linter.parser import AsciiDocParser +from asciidoc_linter.reporter import LintReport, LintError + +# Fixtures + +@pytest.fixture +def mock_parser(): + """Create a mock parser that returns a simple document structure""" + parser = Mock(spec=AsciiDocParser) + parser.parse.return_value = { + 'type': 'document', + 'content': [] + } + return parser + +@pytest.fixture +def mock_rule(): + """Create a mock rule that can be configured to return specific errors""" + rule = Mock() + rule.check.return_value = [] # By default, return no errors + return rule + +@pytest.fixture +def sample_asciidoc(): + """Return a sample AsciiDoc string for testing""" + return """= Title + +== Section 1 + +Some content. + +== Section 2 + +More content. +""" + +# Tests for initialization + +def test_linter_initialization(): + """Test that the linter initializes with correct default rules""" + linter = AsciiDocLinter() + assert len(linter.rules) == 7 # Verify number of default rules + assert hasattr(linter, 'parser') # Verify parser is initialized + +# Tests for lint_string method + +def test_lint_string_no_errors(mock_parser, mock_rule): + """Test linting a string with no errors""" + with patch('asciidoc_linter.linter.AsciiDocParser', return_value=mock_parser): + linter = AsciiDocLinter() + linter.rules = [mock_rule] + + report = linter.lint_string("Some content") + + assert isinstance(report, LintReport) + assert len(report.errors) == 0 + mock_parser.parse.assert_called_once_with("Some content") + mock_rule.check.assert_called_once() + +def test_lint_string_with_errors(mock_parser, mock_rule): + """Test linting a string that contains errors""" + mock_rule.check.return_value = [ + LintError(file=None, line=1, message="Test error") + ] + + with patch('asciidoc_linter.linter.AsciiDocParser', return_value=mock_parser): + linter = AsciiDocLinter() + linter.rules = [mock_rule] + + report = linter.lint_string("Some content", source="test.adoc") + + assert len(report.errors) == 1 + assert report.errors[0].file == "test.adoc" + assert report.errors[0].line == 1 + assert report.errors[0].message == "Test error" + +def test_lint_string_multiple_rules(mock_parser): + """Test that all rules are applied when linting a string""" + rule1 = Mock() + rule1.check.return_value = [LintError(file=None, line=1, message="Error 1")] + rule2 = Mock() + rule2.check.return_value = [LintError(file=None, line=2, message="Error 2")] + + with patch('asciidoc_linter.linter.AsciiDocParser', return_value=mock_parser): + linter = AsciiDocLinter() + linter.rules = [rule1, rule2] + + report = linter.lint_string("Some content") + + assert len(report.errors) == 2 + assert rule1.check.called + assert rule2.check.called + +# Tests for lint_file method + +def test_lint_file_success(tmp_path, sample_asciidoc): + """Test linting a file that exists and is readable""" + test_file = tmp_path / "test.adoc" + test_file.write_text(sample_asciidoc) + + linter = AsciiDocLinter() + report = linter.lint_file(test_file) + + assert isinstance(report, LintReport) + # Note: actual number of errors depends on the rules + +def test_lint_file_not_found(): + """Test linting a file that doesn't exist""" + non_existent_file = Path("non_existent.adoc") + + linter = AsciiDocLinter() + report = linter.lint_file(non_existent_file) + + assert len(report.errors) == 1 + assert "Error reading file" in report.errors[0].message + +def test_lint_file_with_source_tracking(tmp_path, sample_asciidoc, mock_rule): + """Test that file source is correctly tracked in errors""" + test_file = tmp_path / "test.adoc" + test_file.write_text(sample_asciidoc) + + mock_rule.check.return_value = [ + LintError(file=None, line=1, message="Test error") + ] + + linter = AsciiDocLinter() + linter.rules = [mock_rule] + + report = linter.lint_file(test_file) + + assert len(report.errors) == 1 + assert str(test_file) == report.errors[0].file + +# Integration tests + +def test_integration_with_real_rules(): + """Test the linter with actual rules and a sample document""" + linter = AsciiDocLinter() + sample_doc = """= Title + +== Section 1 + +Some content. + +=== Subsection + +More content. +""" + + report = linter.lint_string(sample_doc) + assert isinstance(report, LintReport) + # Note: actual number of errors depends on the implemented rules \ No newline at end of file diff --git a/tests/test_linter.py.meta b/tests/test_linter.py.meta new file mode 100755 index 0000000..014887d --- /dev/null +++ b/tests/test_linter.py.meta @@ -0,0 +1 @@ +Test file for the main linter module \ No newline at end of file diff --git a/tests/test_reporter.py b/tests/test_reporter.py new file mode 100755 index 0000000..eff09fb --- /dev/null +++ b/tests/test_reporter.py @@ -0,0 +1,133 @@ +# test_reporter.py - Tests for the reporter module +""" +Tests for the reporter module that handles formatting of lint results +""" + +import json +import pytest +from asciidoc_linter.reporter import ( + LintError, + LintReport, + Reporter, + JsonReporter, + HtmlReporter +) + +# Test Data + +@pytest.fixture +def sample_error(): + """Create a sample lint error""" + return LintError( + file="test.adoc", + line=42, + message="Test error message" + ) + +@pytest.fixture +def sample_report(sample_error): + """Create a sample report with one error""" + return LintReport([sample_error]) + +@pytest.fixture +def complex_report(): + """Create a more complex report with multiple errors""" + return LintReport([ + LintError("doc1.adoc", 1, "First error"), + LintError("doc1.adoc", 5, "Second error"), + LintError("doc2.adoc", 10, "Error in another file"), + LintError(None, 15, "Error without file") + ]) + +# Test LintError + +def test_lint_error_creation(): + """Test creating a LintError""" + error = LintError("test.adoc", 42, "Test message") + assert error.file == "test.adoc" + assert error.line == 42 + assert error.message == "Test message" + +# Test LintReport + +def test_lint_report_creation(sample_error): + """Test creating a LintReport""" + report = LintReport([sample_error]) + assert len(report.errors) == 1 + assert report.errors[0] == sample_error + +def test_lint_report_bool(sample_report): + """Test boolean evaluation of LintReport""" + assert bool(sample_report) is True + assert bool(LintReport([])) is False + +def test_lint_report_len(complex_report): + """Test len() on LintReport""" + assert len(complex_report) == 4 + +# Test Base Reporter + +def test_base_reporter_format(sample_report): + """Test the base reporter's format_report method""" + reporter = Reporter() + output = reporter.format_report(sample_report) + assert "test.adoc:line 42: Test error message" in output + +def test_base_reporter_multiple_errors(complex_report): + """Test formatting multiple errors""" + reporter = Reporter() + output = reporter.format_report(complex_report) + assert "doc1.adoc:line 1: First error" in output + assert "doc1.adoc:line 5: Second error" in output + assert "doc2.adoc:line 10: Error in another file" in output + assert "line 15: Error without file" in output + +# Test JSON Reporter + +def test_json_reporter_format(sample_report): + """Test JSON formatting of a report""" + reporter = JsonReporter() + output = reporter.format_report(sample_report) + data = json.loads(output) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["file"] == "test.adoc" + assert data[0]["line"] == 42 + assert data[0]["message"] == "Test error message" + +def test_json_reporter_complex(complex_report): + """Test JSON formatting of a complex report""" + reporter = JsonReporter() + output = reporter.format_report(complex_report) + data = json.loads(output) + assert len(data) == 4 + assert data[0]["file"] == "doc1.adoc" + assert data[3]["file"] is None + +# Test HTML Reporter + +def test_html_reporter_format(sample_report): + """Test HTML formatting of a report""" + reporter = HtmlReporter() + output = reporter.format_report(sample_report) + assert "" in output + assert "" in output + assert "test.adoc:Line 42" in output + assert "Test error message" in output + +def test_html_reporter_complex(complex_report): + """Test HTML formatting of a complex report""" + reporter = HtmlReporter() + output = reporter.format_report(complex_report) + assert "doc1.adoc:Line 1" in output + assert "First error" in output + assert "doc2.adoc:Line 10" in output + assert "Error without file" in output + +def test_html_reporter_styling(sample_report): + """Test that HTML output includes CSS styling""" + reporter = HtmlReporter() + output = reporter.format_report(sample_report) + assert "