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

1"""Convert legacy ``commitlint.config.js`` files to ``.commitlintrc.yaml``. 

2 

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

8 

9import re 

10from io import StringIO 

11from pathlib import Path 

12from typing import Any, NamedTuple 

13 

14from ruamel.yaml import YAML 

15 

16 

17class _StringState(NamedTuple): 

18 in_string: bool 

19 string_char: str | None 

20 skip: bool 

21 

22 

23class _CharResult(NamedTuple): 

24 handled: bool 

25 depth: int 

26 current: str 

27 

28 

29class CommitlintConfigConverter: 

30 """Translates a ``commitlint.config.js`` source string to YAML. 

31 

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

35 

36 SEVERITY_MAP = { 

37 0: "disabled", 

38 1: "warning", 

39 2: "error", 

40 } 

41 

42 def js_to_yaml(self, js_content: str) -> str: 

43 """Convert JavaScript config text to YAML text. 

44 

45 Args: 

46 js_content: The contents of a ``commitlint.config.js`` file. 

47 

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) 

54 

55 def _parse_js_config(self, js_content: str) -> dict[str, Any]: 

56 config: dict[str, Any] = {"extends": [], "rules": {}} 

57 

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)] 

71 

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) 

76 

77 return config 

78 

79 def _parse_rules(self, rules_str: str) -> dict[str, Any]: 

80 rules: dict[str, Any] = {} 

81 

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) 

91 

92 for match in matches: 

93 rule_name = match.group(2) 

94 rule_value_str = match.group(3).strip() 

95 

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("'\"") 

102 

103 rules[rule_name] = rule_value 

104 

105 return rules 

106 

107 def _parse_array(self, array_str: str) -> list[Any]: 

108 array_str = array_str.strip("[]").strip() 

109 if not array_str: 

110 return [] 

111 

112 result: list[Any] = [] 

113 current = "" 

114 depth = 0 

115 in_string = False 

116 string_char: str | None = None 

117 

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 

123 

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 

132 

133 current += char 

134 

135 if current.strip(): 

136 result.append(self._parse_value(current.strip())) 

137 

138 return result 

139 

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 ) 

150 

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) 

164 

165 def _parse_value(self, value: str) -> Any: 

166 value = value.strip() 

167 

168 if value.startswith("["): 

169 return self._parse_array(value) 

170 

171 if value.startswith("'") or value.startswith('"'): 

172 return value.strip("'\"") 

173 

174 if value.isdigit(): 

175 return int(value) 

176 

177 if value == "true": 

178 return True 

179 if value == "false": 

180 return False 

181 

182 return value 

183 

184 def _generate_yaml(self, config: dict[str, Any]) -> str: 

185 yaml_config: dict[str, Any] = {} 

186 

187 if config.get("extends"): 

188 yaml_config["extends"] = config["extends"] 

189 

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 

194 

195 yaml = YAML() 

196 yaml.default_flow_style = False 

197 yaml.allow_unicode = True 

198 yaml.width = 80 

199 

200 stream = StringIO() 

201 yaml.dump(yaml_config, stream) 

202 return stream.getvalue() 

203 

204 

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. 

209 

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. 

214 

215 Returns: 

216 The YAML content as a string regardless of whether ``output_path`` 

217 was supplied. 

218 """ 

219 converter = CommitlintConfigConverter() 

220 

221 with input_path.open() as f: 

222 js_content = f.read() 

223 

224 yaml_content = converter.js_to_yaml(js_content) 

225 

226 if output_path: 

227 with output_path.open("w") as f: 

228 f.write(yaml_content) 

229 

230 return yaml_content