#!/usr/bin/env python3
"""
Unified Linter - Modular Linting Framework
Company: Copyright (c) 2025 BTA Design Services
Licensed under the MIT License.
Description: Flexible, plugin-based linting system supporting multiple linters
Usage:
python3 tb_lint.py [options] <file.sv> [<file2.sv> ...]
Options:
--help Show this help message
--config FILE Configuration file (JSON)
--linter NAME Run specific linter (default: all)
--list-linters List available linters
--strict Treat warnings as errors
--json Output in JSON format
--color Enable colored output
-f FILE_LIST File containing list of files (one per line)
-o OUTPUT_FILE Output file for results
Examples:
# Run all linters
python3 tb_lint.py -f file_list.txt
# Run only NaturalDocs linter
python3 tb_lint.py --linter naturaldocs file.sv
# Use custom config
python3 tb_lint.py --config my_config.json -f files.txt
"""
import sys
import os
import json
import argparse
from pathlib import Path
from typing import List, Optional
# Add script directory to path
script_dir = Path(__file__).parent
sys.path.insert(0, str(script_dir))
from core import (
ConfigManager,
LinterRegistry,
get_registry,
BaseLinter,
LinterResult,
RuleSeverity
)
# Import linters to register them
from linters import NaturalDocsLinter, VeribleLinter
[docs]
class Colors:
"""ANSI color codes"""
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
CYAN = '\033[0;36m'
BOLD = '\033[1m'
NC = '\033[0m'
[docs]
class UnifiedLinter:
"""
Unified linting orchestrator
Manages multiple linters and aggregates results
"""
[docs]
def __init__(self, config_file: Optional[str] = None, use_color: bool = False,
strict_mode: bool = False):
"""
Initialize unified linter
Args:
config_file: Path to configuration file
use_color: Enable colored output
strict_mode: Treat warnings as errors
"""
self.config_manager = ConfigManager(config_file)
self.registry = get_registry()
self.use_color = use_color and sys.stdout.isatty()
self.strict_mode = strict_mode
def _color(self, color: str, text: str) -> str:
"""Apply color if enabled"""
if self.use_color:
return f"{color}{text}{Colors.NC}"
return text
[docs]
def list_linters(self) -> List[str]:
"""Get list of available linters"""
return self.registry.list_linters()
[docs]
def run_linter(self, linter_name: str, file_paths: List[str]) -> LinterResult:
"""
Run a specific linter on files
Args:
linter_name: Name of linter to run
file_paths: List of files to check
Returns:
LinterResult with violations found
"""
# Get linter configuration
linter_config = self.config_manager.get_linter_config(linter_name)
# Get linter instance
linter = self.registry.get_linter(linter_name, linter_config)
if not linter:
print(f"ERROR: Linter '{linter_name}' not found", file=sys.stderr)
return LinterResult(linter_name=linter_name)
# Run linter
return linter.lint_files(file_paths)
[docs]
def run_all_linters(self, file_paths: List[str]) -> dict:
"""
Run all enabled linters on files
Args:
file_paths: List of files to check
Returns:
Dictionary mapping linter names to results
"""
results = {}
for linter_name in self.list_linters():
# Check if linter is enabled in configuration
if not self.config_manager.is_linter_enabled(linter_name):
print(f"\n{self._color(Colors.YELLOW, f'Skipping {linter_name} linter (disabled in config)')}")
continue
print(f"\n{self._color(Colors.CYAN, '='*80)}")
print(f"{self._color(Colors.CYAN, f'Running {linter_name} linter...')}")
print(f"{self._color(Colors.CYAN, '='*80)}\n")
result = self.run_linter(linter_name, file_paths)
results[linter_name] = result
return results
[docs]
def print_result(self, result: LinterResult, output_file=None):
"""
Print linter results in human-readable format
Args:
result: LinterResult to print
output_file: File handle for output (default: stdout)
"""
out = output_file if output_file else sys.stdout
# Print violations grouped by file
violations_by_file = {}
for violation in result.violations:
if violation.file not in violations_by_file:
violations_by_file[violation.file] = []
violations_by_file[violation.file].append(violation)
# Print each file's violations
for file_path, violations in violations_by_file.items():
print(f"\n{self._color(Colors.CYAN, f'File: {file_path}')}", file=out)
for violation in sorted(violations, key=lambda v: v.line):
if violation.severity == RuleSeverity.ERROR:
color = Colors.RED
level = "ERROR"
elif violation.severity == RuleSeverity.WARNING:
color = Colors.YELLOW
level = "WARNING"
else:
color = Colors.BLUE
level = "INFO"
print(self._color(color,
f" {file_path}:{violation.line}:{violation.column}: "
f"{violation.rule_id} {level}: {violation.message}"),
file=out)
# Print file errors
for file_path, error_msg in result.errors.items():
print(self._color(Colors.RED, f"\n✗ {file_path}: {error_msg}"), file=out)
# Print summary
print(f"\n{self._color(Colors.CYAN, '='*80)}", file=out)
print(f"{self._color(Colors.CYAN, f'{result.linter_name} Summary')}", file=out)
print(f"{self._color(Colors.CYAN, '='*80)}", file=out)
print(f"Files checked: {result.files_checked}", file=out)
if result.files_failed > 0:
print(self._color(Colors.RED, f"Files failed: {result.files_failed}"), file=out)
print(self._color(Colors.RED, f"Errors: {result.error_count}"), file=out)
print(self._color(Colors.YELLOW, f"Warnings: {result.warning_count}"), file=out)
print(self._color(Colors.BLUE, f"Info: {result.info_count}"), file=out)
[docs]
def print_json(self, results: dict, output_file=None):
"""
Print results in JSON format
Args:
results: Dictionary of linter results
output_file: File handle for output (default: stdout)
"""
out = output_file if output_file else sys.stdout
output = {
'linters': {},
'summary': {
'total_files_checked': 0,
'total_files_failed': 0,
'total_errors': 0,
'total_warnings': 0,
'total_info': 0
}
}
for linter_name, result in results.items():
output['linters'][linter_name] = {
'files_checked': result.files_checked,
'files_failed': result.files_failed,
'errors': result.error_count,
'warnings': result.warning_count,
'info': result.info_count,
'violations': [
{
'file': v.file,
'line': v.line,
'column': v.column,
'severity': v.severity.value,
'message': v.message,
'rule_id': v.rule_id
}
for v in result.violations
],
'errors': result.errors
}
output['summary']['total_files_checked'] += result.files_checked
output['summary']['total_files_failed'] += result.files_failed
output['summary']['total_errors'] += result.error_count
output['summary']['total_warnings'] += result.warning_count
output['summary']['total_info'] += result.info_count
print(json.dumps(output, indent=2), file=out)
[docs]
def print_command_info(self, args, files_to_check: List[str], output_file=None):
"""
Print command line information for tb_lint and each enabled linter
Args:
args: Command line arguments
files_to_check: List of files to check
output_file: File handle for output (default: stdout)
"""
out = output_file if output_file else sys.stdout
# Print header
print(f"{self._color(Colors.CYAN, '='*80)}", file=out)
print(f"{self._color(Colors.CYAN, 'TB_LINT - Unified Linter Framework')}", file=out)
print(f"{self._color(Colors.CYAN, '='*80)}", file=out)
# Print unified linter command
cmd_parts = ["python3 tb_lint.py"]
if args.config:
cmd_parts.append(f"--config {args.config}")
if args.linter:
cmd_parts.append(f"--linter {args.linter}")
if args.strict:
cmd_parts.append("--strict")
if args.json:
cmd_parts.append("--json")
if args.color:
cmd_parts.append("--color")
if args.file_list:
cmd_parts.append(f"-f {args.file_list}")
if args.output:
cmd_parts.append(f"-o {args.output}")
if args.files:
cmd_parts.extend(args.files)
print(f"\n{self._color(Colors.BOLD, 'Unified Linter Command:')}", file=out)
print(f" {' '.join(cmd_parts)}", file=out)
# Print config file
config_display = self.config_manager.config_file or 'configs/lint_config.json (default)'
print(f"\n{self._color(Colors.BOLD, 'Configuration:')}", file=out)
print(f" {config_display}", file=out)
# Print enabled linters and their equivalent commands
print(f"\n{self._color(Colors.BOLD, 'Enabled Linters:')}", file=out)
for linter_name in self.list_linters():
if self.config_manager.is_linter_enabled(linter_name):
linter_config = self.config_manager.get_linter_config(linter_name)
linter = self.registry.get_linter(linter_name, linter_config)
# Generate linter-specific command
if linter_name == "verible":
verible_bin = linter.verible_bin if hasattr(linter, 'verible_bin') else 'verible-verilog-lint'
cmd = f"{verible_bin}"
# Add config file if present
config_file_path = linter_config.get('config_file', '')
if config_file_path:
cmd += f" [config: {config_file_path}]"
# Add file list
if args.file_list:
cmd += f" $(cat {args.file_list})"
elif len(files_to_check) <= 3:
cmd += f" {' '.join(files_to_check)}"
else:
cmd += f" {files_to_check[0]} ... ({len(files_to_check)} files)"
print(f" • {self._color(Colors.GREEN, linter_name)}:", file=out)
print(f" {cmd}", file=out)
elif linter_name == "naturaldocs":
verible_bin = linter.verible_bin if hasattr(linter, 'verible_bin') else 'verible-verilog-syntax'
cmd = f"{verible_bin} --export_json"
# Add config file if present
config_file_path = linter_config.get('config_file', '')
if config_file_path:
cmd += f" [config: {config_file_path}]"
# Add file list
if args.file_list:
cmd += f" $(cat {args.file_list})"
elif len(files_to_check) <= 3:
cmd += f" {' '.join(files_to_check)}"
else:
cmd += f" {files_to_check[0]} ... ({len(files_to_check)} files)"
print(f" • {self._color(Colors.GREEN, linter_name)}:", file=out)
print(f" {cmd}", file=out)
print(f" Note: AST-based linting using Verible parser", file=out)
else:
print(f" • {self._color(Colors.GREEN, linter_name)}", file=out)
print(f"{self._color(Colors.CYAN, '='*80)}", file=out)
print("", file=out)
[docs]
def print_final_summary(self, results: dict, output_file=None):
"""
Print final TB_LINT summary
Args:
results: Dictionary of linter results
output_file: File handle for output (default: stdout)
"""
out = output_file if output_file else sys.stdout
# Calculate total errors
total_errors = sum(r.error_count for r in results.values())
# Print separator line
print("", file=out)
print("=" * 80, file=out)
# Determine pass/fail status
if total_errors > 0:
status_msg = self._color(Colors.RED, "TB_LINT : FAILED")
else:
status_msg = self._color(Colors.GREEN, "TB_LINT : PASSED")
print(status_msg, file=out)
print("=" * 80, file=out)
[docs]
def get_exit_code(self, results: dict) -> int:
"""
Determine exit code based on results
Args:
results: Dictionary of linter results
Returns:
0 if all passed, 1 if violations found
"""
total_errors = sum(r.error_count for r in results.values())
total_warnings = sum(r.warning_count for r in results.values())
if total_errors > 0:
return 1
elif total_warnings > 0 and self.strict_mode:
return 1
return 0
[docs]
def main():
"""Main entry point"""
# Parse config file argument first to get project info for epilog
import sys
config_file = None
for i, arg in enumerate(sys.argv):
if arg in ['-c', '--config'] and i + 1 < len(sys.argv):
config_file = sys.argv[i + 1]
break
# Load config to get project info for epilog
temp_config = ConfigManager(config_file)
project_info = temp_config.get_project_info()
company = project_info.get('company', '')
project_name = project_info.get('name', '')
epilog_text = f"{company} - {project_name}" if company and project_name else None
parser = argparse.ArgumentParser(
description='Unified Linting Framework',
epilog=epilog_text,
formatter_class=argparse.RawDescriptionHelpFormatter if epilog_text else argparse.HelpFormatter
)
parser.add_argument('files', nargs='*', help='Files to check')
parser.add_argument('-f', '--file-list', help='File containing list of files')
parser.add_argument('-o', '--output', help='Output file (default: stdout)')
parser.add_argument('-c', '--config', help='Configuration file (JSON)')
parser.add_argument('--linter', help='Run specific linter (default: all)')
parser.add_argument('--list-linters', action='store_true', help='List available linters')
parser.add_argument('--strict', action='store_true', help='Treat warnings as errors')
parser.add_argument('--json', action='store_true', help='Output in JSON format')
parser.add_argument('--color', action='store_true', help='Enable colored output')
args = parser.parse_args()
# Create unified linter
unified = UnifiedLinter(
config_file=args.config,
use_color=args.color,
strict_mode=args.strict
)
# List linters if requested
if args.list_linters:
print("Available linters:")
for linter_name in unified.list_linters():
print(f" - {linter_name}")
return 0
# Collect files to check
files_to_check = []
if args.files:
# Check if any positional arguments look like file lists (.txt files)
# and provide helpful error message
for file_arg in args.files:
if file_arg.endswith('.txt') and os.path.exists(file_arg):
# Check if it's a file list by looking at first line
with open(file_arg, 'r') as f:
first_line = f.readline().strip()
if first_line.startswith('#') or first_line.endswith('.sv') or first_line.endswith('.svh'):
print(f"ERROR: '{file_arg}' appears to be a file list.", file=sys.stderr)
print(f"Use: python3 tb_lint.py -f {file_arg}", file=sys.stderr)
print(f"Not: python3 tb_lint.py {file_arg}", file=sys.stderr)
return 1
files_to_check.extend(args.files)
if args.file_list:
if not os.path.exists(args.file_list):
print(f"ERROR: File list '{args.file_list}' not found", file=sys.stderr)
return 1
with open(args.file_list, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
files_to_check.append(line)
if not files_to_check:
parser.print_help()
print("\nERROR: No files specified", file=sys.stderr)
print("\nTip: Use -f flag for file lists:", file=sys.stderr)
print(" python3 tb_lint.py -f file_list.txt", file=sys.stderr)
return 1
# Print command info (not for JSON output)
if not args.json:
unified.print_command_info(args, files_to_check)
# Run linter(s)
if args.linter:
# Run specific linter
result = unified.run_linter(args.linter, files_to_check)
results = {args.linter: result}
else:
# Run all linters
results = unified.run_all_linters(files_to_check)
# Output results
try:
if args.output:
with open(args.output, 'w') as out_f:
if args.json:
unified.print_json(results, out_f)
else:
# Print command info to file
unified.print_command_info(args, files_to_check, out_f)
for linter_name, result in results.items():
unified.print_result(result, out_f)
# Print final summary
unified.print_final_summary(results, out_f)
else:
if args.json:
unified.print_json(results)
else:
for linter_name, result in results.items():
unified.print_result(result)
# Print final summary
unified.print_final_summary(results)
except Exception as e:
print(f"ERROR writing output: {e}", file=sys.stderr)
return 1
# Return appropriate exit code
return unified.get_exit_code(results)
if __name__ == '__main__':
sys.exit(main())