11from abc import ABC , abstractmethod
22from collections .abc import Callable , Iterable
3+ from re import Match , search
34
45from pydantic import Field , PositiveInt
56
1011 IdempotentCommandsAvoidRule ,
1112 IdempotentCommandsRule ,
1213 IndentAdjustRule ,
14+ MatchRule ,
1315 NegationDefaultWhenRule ,
1416 NegationDefaultWithRule ,
1517 OrderingRule ,
@@ -133,10 +135,18 @@ def idempotent_for(
133135 other_children : Iterable [HConfigChild ],
134136 ) -> HConfigChild | None :
135137 for rule in self .rules .idempotent_commands :
136- if config .is_lineage_match (rule .match_rules ):
137- for other_child in other_children :
138- if other_child .is_lineage_match (rule .match_rules ):
139- return other_child
138+ if not config .is_lineage_match (rule .match_rules ):
139+ continue
140+
141+ config_key = self ._idempotency_key (config , rule .match_rules )
142+
143+ for other_child in other_children :
144+ if not other_child .is_lineage_match (rule .match_rules ):
145+ continue
146+
147+ if self ._idempotency_key (other_child , rule .match_rules ) == config_key :
148+ return other_child
149+
140150 return None
141151
142152 def negate_with (self , config : HConfigChild ) -> str | None :
@@ -154,6 +164,171 @@ def swap_negation(self, child: HConfigChild) -> HConfigChild:
154164
155165 return child
156166
167+ def _idempotency_key (
168+ self ,
169+ config : HConfigChild ,
170+ match_rules : tuple [MatchRule , ...],
171+ ) -> tuple [str , ...]:
172+ lineage = tuple (config .lineage ())
173+ if len (lineage ) != len (match_rules ):
174+ return ()
175+
176+ return tuple (map (self ._idempotency_component_key , lineage , match_rules ))
177+
178+ def _idempotency_component_key (
179+ self ,
180+ child : HConfigChild ,
181+ rule : MatchRule ,
182+ ) -> str :
183+ text = child .text
184+ normalized_text = text .removeprefix (self .negation_prefix )
185+
186+ parts : list [str ] = []
187+ parts .extend (self ._key_from_equals (rule .equals , text ))
188+ parts .extend (self ._key_from_prefix (rule .startswith , normalized_text ))
189+ parts .extend (self ._key_from_suffix (rule .endswith , normalized_text ))
190+ parts .extend (self ._key_from_contains (rule .contains , normalized_text ))
191+ parts .extend (self ._key_from_regex (rule .re_search , normalized_text , text ))
192+
193+ if not parts :
194+ parts .append (f"text|{ normalized_text } " )
195+
196+ return ";" .join (parts )
197+
198+ @staticmethod
199+ def _key_from_equals (
200+ equals : str | frozenset [str ] | None , text : str
201+ ) -> list [str ]:
202+ if equals is None :
203+ return []
204+ if isinstance (equals , str ):
205+ return [f"equals|{ equals } " ]
206+ return [f"equals|{ text } " ]
207+
208+ def _key_from_prefix (
209+ self ,
210+ prefix : str | tuple [str , ...] | None ,
211+ normalized_text : str ,
212+ ) -> list [str ]:
213+ if prefix is None :
214+ return []
215+ matched = self ._match_prefix (normalized_text , prefix )
216+ if matched is None :
217+ return []
218+ return [f"startswith|{ matched } " ]
219+
220+ def _key_from_suffix (
221+ self ,
222+ suffix : str | tuple [str , ...] | None ,
223+ normalized_text : str ,
224+ ) -> list [str ]:
225+ if suffix is None :
226+ return []
227+ matched = self ._match_suffix (normalized_text , suffix )
228+ if matched is None :
229+ return []
230+ return [f"endswith|{ matched } " ]
231+
232+ def _key_from_contains (
233+ self ,
234+ contains : str | tuple [str , ...] | None ,
235+ normalized_text : str ,
236+ ) -> list [str ]:
237+ if contains is None :
238+ return []
239+ matched = self ._match_contains (normalized_text , contains )
240+ if matched is None :
241+ return []
242+ return [f"contains|{ matched } " ]
243+
244+ def _key_from_regex (
245+ self ,
246+ pattern : str | None ,
247+ normalized_text : str ,
248+ original_text : str ,
249+ ) -> list [str ]:
250+ if pattern is None :
251+ return []
252+
253+ match = search (pattern , normalized_text )
254+ match_source = normalized_text
255+ if match is None :
256+ match = search (pattern , original_text )
257+ match_source = original_text
258+
259+ if match is None :
260+ return []
261+
262+ regex_key = self ._normalize_regex_key (pattern , match_source , match )
263+ return [f"re|{ regex_key } " ]
264+
265+ @staticmethod
266+ def _match_prefix (value : str , prefix : str | tuple [str , ...]) -> str | None :
267+ if isinstance (prefix , tuple ):
268+ matches = [candidate for candidate in prefix if value .startswith (candidate )]
269+ if matches :
270+ return max (matches , key = len )
271+ return None
272+
273+ if value .startswith (prefix ):
274+ return prefix
275+
276+ return None
277+
278+ @staticmethod
279+ def _match_suffix (value : str , suffix : str | tuple [str , ...]) -> str | None :
280+ if isinstance (suffix , tuple ):
281+ matches = [candidate for candidate in suffix if value .endswith (candidate )]
282+ if matches :
283+ return max (matches , key = len )
284+ return None
285+
286+ if value .endswith (suffix ):
287+ return suffix
288+
289+ return None
290+
291+ @staticmethod
292+ def _match_contains (
293+ value : str , contains : str | tuple [str , ...]
294+ ) -> str | None :
295+ if isinstance (contains , tuple ):
296+ matches = [candidate for candidate in contains if candidate in value ]
297+ if matches :
298+ return max (matches , key = len )
299+ return None
300+
301+ if contains in value :
302+ return contains
303+
304+ return None
305+
306+ @staticmethod
307+ def _normalize_regex_key (pattern : str , value : str , match : Match [str ]) -> str :
308+ result = match .group (0 )
309+
310+ if match .re .groups :
311+ groups = tuple (g or "" for g in match .groups ())
312+ if any (groups ):
313+ normalized_groups = tuple (group .strip () for group in groups )
314+ if any (normalized_groups ):
315+ return "|" .join (normalized_groups )
316+
317+ trimmed_pattern = pattern .rstrip ("$" )
318+ for suffix in (".*" , ".+" ):
319+ if trimmed_pattern .endswith (suffix ):
320+ candidate_pattern = trimmed_pattern [: - len (suffix )]
321+ if not candidate_pattern :
322+ break
323+ trimmed_match = search (candidate_pattern , value )
324+ if trimmed_match is not None :
325+ candidate = trimmed_match .group (0 ).strip ()
326+ if candidate :
327+ return candidate
328+ break
329+
330+ return result .strip ()
331+
157332 @property
158333 def declaration_prefix (self ) -> str :
159334 return ""
0 commit comments