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

1"""Parser for shellcheck JSON output. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import json 

11from typing import Any 

12 

13from loguru import logger 

14 

15from lintro.parsers.shellcheck.shellcheck_issue import ShellcheckIssue 

16 

17 

18def _safe_int(value: Any, default: int = 0) -> int: 

19 """Safely convert a value to int with fallback. 

20 

21 Args: 

22 value: Value to convert. 

23 default: Default value if conversion fails. 

24 

25 Returns: 

26 Integer value or default. 

27 """ 

28 try: 

29 return int(value) 

30 except (TypeError, ValueError): 

31 return default 

32 

33 

34def parse_shellcheck_output(output: str | None) -> list[ShellcheckIssue]: 

35 """Parse shellcheck json1 output into a list of ShellcheckIssue objects. 

36 

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 ] 

50 

51 Args: 

52 output: The raw JSON output from shellcheck, or None. 

53 

54 Returns: 

55 List of ShellcheckIssue objects. 

56 """ 

57 issues: list[ShellcheckIssue] = [] 

58 

59 # Handle None or empty output 

60 if output is None or not output.strip(): 

61 return issues 

62 

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 

69 

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 

78 

79 for item in data: 

80 if not isinstance(item, dict): 

81 continue 

82 

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", "")) 

92 

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) 

98 

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 ) 

111 

112 return issues