code: /* LanguageTool, a natural language style checker 
   * Copyright (C) 2021 Fabian Richter
   * 
   * This library is free software; you can redistribute it and/or
   * modify it under the terms of the GNU Lesser General Public
   * License as published by the Free Software Foundation; either
   * version 2.1 of the License, or (at your option) any later version.
   *
   * This library is distributed in the hope that it will be useful,
   * but WITHOUT ANY WARRANTY; without even the implied warranty of
   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   * Lesser General Public License for more details.
   *
   * You should have received a copy of the GNU Lesser General Public
   * License along with this library; if not, write to the Free Software
   * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301
   * USA
   */
  
  package org.languagetool.rules.patterns;
  
  import org.languagetool.AnalyzedSentence;
  import org.languagetool.Language;
  import org.languagetool.rules.Rule;
  import org.languagetool.rules.RuleMatch;
  import org.languagetool.rules.SameRuleGroupFilter;
  import org.languagetool.rules.TextLevelRule;
  
  import java.io.IOException;
  import java.util.*;
  import java.util.stream.Collectors;
  
  public class RepeatedPatternRuleTransformer implements PatternRuleTransformer {
    
    protected int defaultMaxDistanceTokens = 60; // number of tokens 
    protected final Language transformerLanguage;
  
    public RepeatedPatternRuleTransformer(Language lang) {
      transformerLanguage = lang;
    }
  
    /**
     * Wrapper for loaded {@link AbstractPatternRule} instances to act as text-level rules
     */
    public class RepeatedPatternRule extends TextLevelRule {
  
      protected final Language ruleLanguage;
      
      RepeatedPatternRule(List<AbstractPatternRule> rules, Language lang) {
        this.rules = Collections.unmodifiableList(rules);
        this.ruleLanguage = lang;
        setPremium(rules.stream().anyMatch(r -> r.isPremium()));
      }
  
      private final List<AbstractPatternRule> rules;
  
      public List<AbstractPatternRule> getWrappedRules() {
        return rules;
      }
  
      @Override
      public String getId() {
        return rules.get(0).getId();
      }
  
      @Override
      public String getDescription() {
        return rules.get(0).getDescription();
      }
      
      @Override
      public RuleMatch[] match(List<AnalyzedSentence> sentences) throws IOException {
        List<RuleMatch> matches = new ArrayList<>();
        int offsetChars = 0;
        int offsetTokens = 0;
        int prevFromToken = 0;
        int prevMatches = 0;
        List<Integer> distancesBetweenMatches = new ArrayList<>();
        // we need to adjust offsets since each pattern rule returns offsets relative to the sentence, not text
        for (AnalyzedSentence s : sentences) {
          List<RuleMatch> sentenceMatches = new ArrayList<>();
          for (AbstractPatternRule rule : rules) {
            RuleMatch[] ruleMatches = rule.match(s);
            sentenceMatches.addAll(Arrays.asList(ruleMatches));
          }
          sentenceMatches = new SameRuleGroupFilter().filter(sentenceMatches);
          // no sorting: SameRuleGroupFilter sorts rule matches already
          int sentenceLenghtTokens = s.getTokensWithoutWhitespace().length;
          for (RuleMatch m : sentenceMatches) {
            int fromToken = 0;
            while (fromToken < sentenceLenghtTokens
                && s.getTokensWithoutWhitespace()[fromToken].getStartPos() < m.getFromPos()) {
              fromToken++;
            }
            fromToken += offsetTokens;
            int fromPos = m.getFromPos() + offsetChars;
            int toPos = m.getToPos() + offsetChars;
            m.setOffsetPosition(fromPos, toPos);
            int maxDistanceTokens = m.getRule().getDistanceTokens();
            if (maxDistanceTokens < 1) {
              maxDistanceTokens = defaultMaxDistanceTokens * m.getRule().getMinPrevMatches();
            }
            distancesBetweenMatches.add(fromToken - prevFromToken);
            if (prevMatches >= m.getRule().getMinPrevMatches()
                && isDistanceValid(distancesBetweenMatches, maxDistanceTokens, m.getRule().getMinPrevMatches())) {
              matches.add(m);
            }
            prevFromToken = fromToken;
            prevMatches++;
          }
          offsetChars += s.getText().length();
          offsetTokens += sentenceLenghtTokens - 1; // -1 -> not counting SENT_START
        }
        return matches.toArray(new RuleMatch[0]); 
      }
  
      @Override
      public int minToCheckParagraph() {
        // TODO: what should we use here? calculate based on min_prev_matches?
        return 0;
      }
  
      @Override
      public boolean supportsLanguage(Language language) {
        return language.equalsConsiderVariantsIfSpecified(this.ruleLanguage);
      }
  
      private boolean isDistanceValid(List<Integer> distancesBetweenMatches, int maxDistanceTokens, int minPrevMatches) {
        int i = 0;
        int size = distancesBetweenMatches.size();
        int distance = 0;
        while (i < minPrevMatches && i < size) {
          distance += distancesBetweenMatches.get(size - 1 - i);
          i++;
        }
        return distance < maxDistanceTokens;
      }
  
    }
  
    @Override
    public TransformedRules apply(List<AbstractPatternRule> patternRules) {
      List<AbstractPatternRule> remaining = new ArrayList<>();
      Map<String, List<AbstractPatternRule>> toTransform = new HashMap<>();
      // rules in a rule group / with the same ID should be combined so repetitions of similar patterns are matched
      for (AbstractPatternRule abstractPatternRule : patternRules) {
        if (abstractPatternRule.getMinPrevMatches() > 0) {
          toTransform.compute(abstractPatternRule.getId(), (id, rules) -> {
            if (rules == null) {
              return new ArrayList<>(Collections.singletonList(abstractPatternRule));
            } else {
              rules.add(abstractPatternRule);
              return rules;
            }
          });
        } else {
          remaining.add(abstractPatternRule);
        }
      }
      List<Rule> transformed = toTransform.values().stream()
        .map(group -> new RepeatedPatternRule(group, transformerLanguage))
        .collect(Collectors.toList());
  
      return new TransformedRules(remaining, transformed);
    }
   }
  