Coverage for lintro / utils / native_parsers.py: 66%
164 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"""Native configuration parsers for various tools.
3Handles loading and parsing of tool-specific configuration files (JSON, YAML, etc.).
4"""
6import json
7from pathlib import Path
8from typing import Any
10from loguru import logger
12from lintro.enums.tool_name import ToolName
13from lintro.utils.jsonc import (
14 strip_jsonc_comments as _strip_jsonc_comments,
15)
16from lintro.utils.jsonc import (
17 strip_trailing_commas as _strip_trailing_commas,
18)
20try:
21 import yaml
22except ImportError:
23 yaml = None # type: ignore[assignment]
25# Configuration file patterns for different tools
26YAMLLINT_CONFIG_FILES = [".yamllint", ".yamllint.yaml", ".yamllint.yml"]
27MARKDOWNLINT_CONFIG_FILES = [
28 ".markdownlint.json",
29 ".markdownlint.yaml",
30 ".markdownlint.yml",
31 ".markdownlint.jsonc",
32]
33TSC_CONFIG_FILES = ["tsconfig.json"]
34MYPY_CONFIG_FILES = ["mypy.ini", ".mypy.ini"]
35OXLINT_CONFIG_FILES = [".oxlintrc.json", "oxlint.json"]
36OXFMT_CONFIG_FILES = [".oxfmtrc.json", ".oxfmtrc.jsonc"]
39def _load_json_config(config_path: Path) -> dict[str, Any]:
40 """Load and parse a JSON configuration file.
42 Args:
43 config_path: Path to the JSON configuration file.
45 Returns:
46 Parsed configuration as dict, or empty dict on error.
47 """
48 try:
49 with config_path.open(encoding="utf-8") as f:
50 loaded = json.load(f)
51 return loaded if isinstance(loaded, dict) else {}
52 except json.JSONDecodeError as e:
53 logger.warning(
54 f"Failed to parse JSON config {config_path}: {e.msg} "
55 f"(line {e.lineno}, col {e.colno})",
56 )
57 return {}
58 except FileNotFoundError:
59 logger.debug(f"Config file not found: {config_path}")
60 return {}
61 except OSError as e:
62 logger.debug(f"Could not read config file {config_path}: {e}")
63 return {}
66def _load_native_tool_config(tool_name: str) -> dict[str, Any]:
67 """Load native configuration for a specific tool.
69 Args:
70 tool_name: Name of the tool
72 Returns:
73 Native configuration dictionary
74 """
75 from lintro.utils.config import load_pyproject
77 # Convert string to ToolName enum for consistent comparisons
78 try:
79 tool_enum = ToolName(tool_name)
80 except ValueError:
81 # Unknown tool, return empty config
82 return {}
84 pyproject = load_pyproject()
85 tool_section_raw = pyproject.get("tool", {})
86 tool_section = tool_section_raw if isinstance(tool_section_raw, dict) else {}
88 # Tools with pyproject.toml config
89 if tool_enum in (ToolName.RUFF, ToolName.BLACK, ToolName.BANDIT):
90 config_value = tool_section.get(tool_name, {})
91 return config_value if isinstance(config_value, dict) else {}
93 # Yamllint: check native config files (not pyproject.toml)
94 if tool_enum == ToolName.YAMLLINT:
95 for config_file in YAMLLINT_CONFIG_FILES:
96 config_path = Path(config_file)
97 if config_path.exists():
98 if yaml is None:
99 logger.debug(
100 f"[UnifiedConfig] Found {config_file} but yaml not installed",
101 )
102 return {}
103 try:
104 with config_path.open(encoding="utf-8") as f:
105 content = yaml.safe_load(f)
106 return content if isinstance(content, dict) else {}
107 except yaml.YAMLError as e:
108 logger.warning(
109 f"Failed to parse yamllint config {config_file}: {e}",
110 )
111 except OSError as e:
112 logger.debug(f"Could not read yamllint config {config_file}: {e}")
113 return {}
115 # Markdownlint: check config files
116 if tool_enum == ToolName.MARKDOWNLINT:
117 for config_file in MARKDOWNLINT_CONFIG_FILES:
118 config_path = Path(config_file)
119 if not config_path.exists():
120 continue
122 # Handle JSON/JSONC files
123 if config_file.endswith((".json", ".jsonc")):
124 try:
125 with config_path.open(encoding="utf-8") as f:
126 content = f.read()
127 # Strip JSONC comments safely (preserves strings)
128 content = _strip_jsonc_comments(content)
129 loaded = json.loads(content)
130 return loaded if isinstance(loaded, dict) else {}
131 except json.JSONDecodeError as e:
132 logger.warning(
133 f"Failed to parse markdownlint config {config_file}: {e.msg} "
134 f"(line {e.lineno}, col {e.colno})",
135 )
136 except FileNotFoundError:
137 logger.debug(f"Markdownlint config not found: {config_file}")
138 except OSError as e:
139 logger.debug(f"Could not read markdownlint config: {e}")
141 # Handle YAML files
142 elif config_file.endswith((".yaml", ".yml")):
143 if yaml is None:
144 logger.warning(
145 "PyYAML not available; cannot parse .markdownlint.yaml",
146 )
147 continue
148 try:
149 with config_path.open(encoding="utf-8") as f:
150 content = yaml.safe_load(f)
151 # Handle multi-document YAML (coerce to dict)
152 if isinstance(content, list) and len(content) > 0:
153 logger.debug(
154 "[UnifiedConfig] Markdownlint YAML config "
155 "contains multiple documents, using first "
156 "document",
157 )
158 content = content[0]
159 if isinstance(content, dict):
160 return content
161 except yaml.YAMLError as e:
162 logger.warning(
163 f"Failed to parse markdownlint config {config_path}: {e}",
164 )
165 except (OSError, UnicodeDecodeError) as e:
166 logger.debug(
167 f"Could not read markdownlint config {config_path}: "
168 f"{type(e).__name__}: {e}",
169 )
170 return {}
172 # TSC (TypeScript Compiler): check tsconfig.json
173 if tool_enum == ToolName.TSC:
174 for config_file in TSC_CONFIG_FILES:
175 config_path = Path(config_file)
176 if config_path.exists():
177 try:
178 content = config_path.read_text(encoding="utf-8")
179 # tsconfig.json may have comments and trailing commas (JSONC format)
180 content = _strip_jsonc_comments(content)
181 content = _strip_trailing_commas(content)
182 loaded = json.loads(content)
183 if isinstance(loaded, dict):
184 # Return a summary of the most relevant options
185 result: dict[str, Any] = {}
186 if "extends" in loaded:
187 result["extends"] = loaded["extends"]
188 if "compilerOptions" in loaded:
189 result["compilerOptions"] = loaded["compilerOptions"]
190 if "include" in loaded:
191 result["include"] = loaded["include"]
192 if "exclude" in loaded:
193 result["exclude"] = loaded["exclude"]
194 return result if result else loaded
195 except json.JSONDecodeError as e:
196 logger.warning(
197 f"Failed to parse tsconfig.json: {e.msg} "
198 f"(line {e.lineno}, col {e.colno})",
199 )
200 except OSError as e:
201 logger.debug(f"Could not read tsconfig.json: {e}")
202 return {}
204 # Mypy: check mypy.ini or pyproject.toml [tool.mypy]
205 if tool_enum == ToolName.MYPY:
206 # First check pyproject.toml [tool.mypy]
207 mypy_config = tool_section.get("mypy", {})
208 if isinstance(mypy_config, dict) and mypy_config:
209 return mypy_config
211 # Then check mypy.ini / .mypy.ini
212 for config_file in MYPY_CONFIG_FILES:
213 config_path = Path(config_file)
214 if config_path.exists():
215 try:
216 import configparser
218 parser = configparser.ConfigParser(interpolation=None)
219 parser.read(config_path, encoding="utf-8")
220 # Convert to dict, focusing on [mypy] section
221 result = {}
222 if "mypy" in parser:
223 result = dict(parser["mypy"])
224 return result
225 except (configparser.Error, OSError, UnicodeDecodeError) as e:
226 logger.debug(f"Could not read mypy config {config_file}: {e}")
227 return {}
229 # Oxlint: check native config files
230 if tool_enum == ToolName.OXLINT:
231 for config_file in OXLINT_CONFIG_FILES:
232 config_path = Path(config_file)
233 if config_path.exists():
234 return _load_json_config(config_path)
235 return {}
237 # Oxfmt: check native config files (supports JSONC comments)
238 if tool_enum == ToolName.OXFMT:
239 for config_file in OXFMT_CONFIG_FILES:
240 config_path = Path(config_file)
241 if not config_path.exists():
242 continue
243 try:
244 with config_path.open(encoding="utf-8") as f:
245 content = f.read()
246 # Strip JSONC comments and trailing commas safely
247 if config_file.endswith(".jsonc"):
248 content = _strip_jsonc_comments(content)
249 content = _strip_trailing_commas(content)
250 loaded = json.loads(content)
251 return loaded if isinstance(loaded, dict) else {}
252 except json.JSONDecodeError as e:
253 logger.warning(
254 f"Failed to parse oxfmt config {config_file}: {e.msg} "
255 f"(line {e.lineno}, col {e.colno})",
256 )
257 except FileNotFoundError:
258 logger.debug(f"Oxfmt config not found: {config_file}")
259 except OSError as e:
260 logger.debug(f"Could not read oxfmt config {config_file}: {e}")
261 return {}
263 return {}