Coverage for lintro / parsers / shellcheck / shellcheck_parser.py: 95%
40 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Parser for shellcheck JSON output.
3This module provides parsing functionality for ShellCheck's json1 output format.
4ShellCheck is a static analysis tool for shell scripts that identifies bugs,
5syntax issues, and suggests improvements.
6"""
8from __future__ import annotations
10import json
11from typing import Any
13from loguru import logger
15from lintro.parsers.shellcheck.shellcheck_issue import ShellcheckIssue
18def _safe_int(value: Any, default: int = 0) -> int:
19 """Safely convert a value to int with fallback.
21 Args:
22 value: Value to convert.
23 default: Default value if conversion fails.
25 Returns:
26 Integer value or default.
27 """
28 try:
29 return int(value)
30 except (TypeError, ValueError):
31 return default
34def parse_shellcheck_output(output: str | None) -> list[ShellcheckIssue]:
35 """Parse shellcheck json1 output into a list of ShellcheckIssue objects.
37 ShellCheck outputs JSON in the following format when using --format=json1:
38 [
39 {
40 "file": "script.sh",
41 "line": 10,
42 "endLine": 10,
43 "column": 5,
44 "endColumn": 10,
45 "level": "warning",
46 "code": 2086,
47 "message": "Double quote to prevent globbing and word splitting."
48 }
49 ]
51 Args:
52 output: The raw JSON output from shellcheck, or None.
54 Returns:
55 List of ShellcheckIssue objects.
56 """
57 issues: list[ShellcheckIssue] = []
59 # Handle None or empty output
60 if output is None or not output.strip():
61 return issues
63 try:
64 parsed = json.loads(output)
65 except json.JSONDecodeError as e:
66 # If JSON parsing fails, return empty list
67 logger.debug(f"Failed to parse shellcheck output as JSON: {e}")
68 return issues
70 # Handle json1 format: {"comments": [...]} or plain JSON format: [...]
71 # Note: data may contain non-dict items, filtered by isinstance check below
72 if isinstance(parsed, dict) and "comments" in parsed:
73 data: list[Any] = parsed["comments"]
74 elif isinstance(parsed, list):
75 data = parsed
76 else:
77 return issues
79 for item in data:
80 if not isinstance(item, dict):
81 continue
83 # Extract required fields with defaults (using safe conversion)
84 file_path: str = str(item.get("file", ""))
85 line: int = _safe_int(item.get("line", 0))
86 column: int = _safe_int(item.get("column", 0))
87 end_line: int = _safe_int(item.get("endLine", 0))
88 end_column: int = _safe_int(item.get("endColumn", 0))
89 level: str = str(item.get("level", "error"))
90 code: int | str = item.get("code", 0)
91 message: str = str(item.get("message", ""))
93 # Format code as SC#### string (handle both int and numeric string codes)
94 if isinstance(code, int) or (isinstance(code, str) and code.isdigit()):
95 code_str = f"SC{code}"
96 else:
97 code_str = str(code)
99 issues.append(
100 ShellcheckIssue(
101 file=file_path,
102 line=line,
103 column=column,
104 end_line=end_line,
105 end_column=end_column,
106 level=level,
107 code=code_str,
108 message=message,
109 ),
110 )
112 return issues