Coverage for src / python_commitlint / config / converter.py: 94%
125 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
1"""Convert legacy ``commitlint.config.js`` files to ``.commitlintrc.yaml``.
3The conversion is regex-based — JavaScript is parsed only well enough to
4extract the ``extends`` and ``rules`` blocks. Inputs that rely on advanced
5JS features (template literals, computed keys, comments inside rules) may
6not round-trip cleanly.
7"""
9import re
10from io import StringIO
11from pathlib import Path
12from typing import Any, NamedTuple
14from ruamel.yaml import YAML
17class _StringState(NamedTuple):
18 in_string: bool
19 string_char: str | None
20 skip: bool
23class _CharResult(NamedTuple):
24 handled: bool
25 depth: int
26 current: str
29class CommitlintConfigConverter:
30 """Translates a ``commitlint.config.js`` source string to YAML.
32 Use :meth:`js_to_yaml` for in-memory conversion or the module-level
33 :func:`convert_js_to_yaml` for the file-based variant.
34 """
36 SEVERITY_MAP = {
37 0: "disabled",
38 1: "warning",
39 2: "error",
40 }
42 def js_to_yaml(self, js_content: str) -> str:
43 """Convert JavaScript config text to YAML text.
45 Args:
46 js_content: The contents of a ``commitlint.config.js`` file.
48 Returns:
49 A YAML document equivalent to the parsed ``extends`` and
50 ``rules`` declarations.
51 """
52 config = self._parse_js_config(js_content)
53 return self._generate_yaml(config)
55 def _parse_js_config(self, js_content: str) -> dict[str, Any]:
56 config: dict[str, Any] = {"extends": [], "rules": {}}
58 extends_match = re.search(
59 r"extends\s*:\s*\[(.*?)\]", js_content, re.DOTALL
60 )
61 if extends_match:
62 extends_str = extends_match.group(1)
63 extends_list = re.findall(r"['\"]([^'\"]+)['\"]", extends_str)
64 config["extends"] = extends_list
65 else:
66 extends_match = re.search(
67 r"extends\s*:\s*['\"]([^'\"]+)['\"]", js_content
68 )
69 if extends_match:
70 config["extends"] = [extends_match.group(1)]
72 rules_match = re.search(r"rules\s*:\s*\{(.*?)\}", js_content, re.DOTALL)
73 if rules_match:
74 rules_str = rules_match.group(1)
75 config["rules"] = self._parse_rules(rules_str)
77 return config
79 def _parse_rules(self, rules_str: str) -> dict[str, Any]:
80 rules: dict[str, Any] = {}
82 # The bracketed alternative supports one level of nesting so rules
83 # like `'scope-enum': [2, 'always', ['api', 'ui']]` parse correctly.
84 # A naive lazy `\[.*?\]` would terminate at the first `]` and drop
85 # the inner array.
86 rule_pattern = (
87 r"(['\"]?)(\w+(?:-\w+)*)\1\s*:\s*"
88 r"(\[(?:[^\[\]]|\[[^\[\]]*\])*\]|\d+|['\"][^'\"]*['\"])"
89 )
90 matches = re.finditer(rule_pattern, rules_str, re.DOTALL)
92 for match in matches:
93 rule_name = match.group(2)
94 rule_value_str = match.group(3).strip()
96 if rule_value_str.startswith("["):
97 rule_value = self._parse_array(rule_value_str)
98 elif rule_value_str.isdigit():
99 rule_value = [int(rule_value_str), "always"]
100 else:
101 rule_value = rule_value_str.strip("'\"")
103 rules[rule_name] = rule_value
105 return rules
107 def _parse_array(self, array_str: str) -> list[Any]:
108 array_str = array_str.strip("[]").strip()
109 if not array_str:
110 return []
112 result: list[Any] = []
113 current = ""
114 depth = 0
115 in_string = False
116 string_char: str | None = None
118 for char in array_str:
119 state = self._handle_string_state(char, in_string, string_char)
120 in_string, string_char = state.in_string, state.string_char
121 if state.skip:
122 continue
124 if not in_string:
125 char_result = self._handle_special_chars(
126 char, depth, current, result
127 )
128 depth = char_result.depth
129 if char_result.handled:
130 current = char_result.current
131 continue
133 current += char
135 if current.strip():
136 result.append(self._parse_value(current.strip()))
138 return result
140 def _handle_string_state(
141 self, char: str, in_string: bool, string_char: str | None
142 ) -> _StringState:
143 if char in ("'", '"') and not in_string:
144 return _StringState(in_string=True, string_char=char, skip=True)
145 if char == string_char and in_string:
146 return _StringState(in_string=False, string_char=None, skip=True)
147 return _StringState(
148 in_string=in_string, string_char=string_char, skip=False
149 )
151 def _handle_special_chars(
152 self, char: str, depth: int, current: str, result: list[Any]
153 ) -> _CharResult:
154 if char == "[":
155 return _CharResult(handled=False, depth=depth + 1, current=current)
156 if char == "]":
157 return _CharResult(handled=False, depth=depth - 1, current=current)
158 if char == "," and depth == 0:
159 value = current.strip()
160 if value:
161 result.append(self._parse_value(value))
162 return _CharResult(handled=True, depth=depth, current="")
163 return _CharResult(handled=False, depth=depth, current=current)
165 def _parse_value(self, value: str) -> Any:
166 value = value.strip()
168 if value.startswith("["):
169 return self._parse_array(value)
171 if value.startswith("'") or value.startswith('"'):
172 return value.strip("'\"")
174 if value.isdigit():
175 return int(value)
177 if value == "true":
178 return True
179 if value == "false":
180 return False
182 return value
184 def _generate_yaml(self, config: dict[str, Any]) -> str:
185 yaml_config: dict[str, Any] = {}
187 if config.get("extends"):
188 yaml_config["extends"] = config["extends"]
190 if config.get("rules"):
191 yaml_config["rules"] = {}
192 for rule_name, rule_value in config["rules"].items():
193 yaml_config["rules"][rule_name] = rule_value
195 yaml = YAML()
196 yaml.default_flow_style = False
197 yaml.allow_unicode = True
198 yaml.width = 80
200 stream = StringIO()
201 yaml.dump(yaml_config, stream)
202 return stream.getvalue()
205def convert_js_to_yaml(
206 input_path: Path, output_path: Path | None = None
207) -> str:
208 """Convert a JS config file to YAML, optionally writing the result.
210 Args:
211 input_path: Path to the source ``commitlint.config.js``.
212 output_path: Optional destination path. When provided, the
213 converted YAML is written there.
215 Returns:
216 The YAML content as a string regardless of whether ``output_path``
217 was supplied.
218 """
219 converter = CommitlintConfigConverter()
221 with input_path.open() as f:
222 js_content = f.read()
224 yaml_content = converter.js_to_yaml(js_content)
226 if output_path:
227 with output_path.open("w") as f:
228 f.write(yaml_content)
230 return yaml_content