Browse Source

Merge remote-tracking branch 'origin/master' into feature/metainf

# Conflicts:
#	jplag/src/main/java/de/jplag/options/LanguageOption.java
Dominik Fuchß 4 years ago
parent
commit
b36e65b7ed
25 changed files with 1551 additions and 282 deletions
  1. 16 0
      jplag.frontend.rlang/README.md
  2. 45 0
      jplag.frontend.rlang/pom.xml
  3. 216 0
      jplag.frontend.rlang/src/main/antlr4/de/jplag/R/grammar/R.g4
  4. 83 0
      jplag.frontend.rlang/src/main/antlr4/de/jplag/R/grammar/RFilter.g4
  5. 158 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/JplagRListener.java
  6. 71 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/Language.java
  7. 342 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/RCombinedBaseListener.java
  8. 97 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/RParserAdapter.java
  9. 46 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/RToken.java
  10. 39 0
      jplag.frontend.rlang/src/main/java/de/jplag/R/RTokenConstants.java
  11. 114 0
      jplag.frontend.rlang/src/test/java/de/jplag/R/RFrontendTest.java
  12. 37 0
      jplag.frontend.rlang/src/test/resources/de/jplag/R/Complete.R
  13. 92 0
      jplag.frontend.rlang/src/test/resources/de/jplag/R/Game.R
  14. 4 0
      jplag/pom.xml
  15. 14 5
      jplag/src/main/java/de/jplag/clustering/Cluster.java
  16. 1 1
      jplag/src/main/java/de/jplag/clustering/ClusteringAdapter.java
  17. 28 9
      jplag/src/main/java/de/jplag/clustering/ClusteringResult.java
  18. 3 8
      jplag/src/main/java/de/jplag/reporting/reportobject/ReportObjectFactory.java
  19. 26 0
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapper.java
  20. 2 28
      jplag/src/main/java/de/jplag/reporting/reportobject/model/Cluster.java
  21. 4 13
      jplag/src/test/java/de/jplag/clustering/ClusterTest.java
  22. 0 215
      jplag/src/test/java/de/jplag/clustering/ClusteringRealDataTest.java
  23. 59 3
      jplag/src/test/java/de/jplag/clustering/ClusteringResultTest.java
  24. 48 0
      jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapperTest.java
  25. 6 0
      pom.xml

+ 16 - 0
jplag.frontend.rlang/README.md

@@ -0,0 +1,16 @@
+# JPlag R language frontend
+
+The JPlag R frontend allows the use of JPlag with submissions in R. <br>
+It was in part adapted from a [JPLag fork by CodeGra-de](https://github.com/CodeGra-de/jplag/tree/master/jplag.frontend.R).
+
+### R specification compatibility
+The underlying [grammar definition](https://github.com/antlr/grammars-v4/tree/master/r) was first created in June 2013, when R 3.0.1 was current. The latest commit is from April 2018, when R 3.5.0 was just released. Whether the grammar has been made to comply with any specific version of the R specification is unclear. Even if some parsing errors occur, the parser should be able to recover and still produce a valid analysis.
+
+### Token Extraction
+
+The choice of tokens is based directly on the CodeGra-de version, whereas the extraction process itself contains some fixes.
+
+Like in other frontends, e.g. for Java and C#, the tokens account for the beginning and the end of control flow structures, for control flow keywords, and some kinds of expressions. As R is very different from other programming languages in JPlag, it remains to be seen whether the R frontend can hold up to the others.
+
+### Usage
+To use the R frontend, add the `-l R` flag in the CLI, or use a `JPlagOption` object set to `LanguageOption.R` in the Java API as described in the usage information in the [readme of the main project](https://github.com/jplag/JPlag#usage) and [in the wiki](https://github.com/jplag/JPlag/wiki/1.-How-to-Use-JPlag).

+ 45 - 0
jplag.frontend.rlang/pom.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>de.jplag</groupId>
+        <artifactId>aggregator</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <artifactId>rlang</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.antlr</groupId>
+            <artifactId>antlr4-runtime</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>de.jplag</groupId>
+            <artifactId>frontend-utils</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>de.jplag</groupId>
+            <artifactId>frontend-testutils</artifactId>
+            <version>${revision}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.antlr</groupId>
+                <artifactId>antlr4-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>antlr4</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+</project>

+ 216 - 0
jplag.frontend.rlang/src/main/antlr4/de/jplag/R/grammar/R.g4

@@ -0,0 +1,216 @@
+/*
+ [The "BSD licence"]
+ Copyright (c) 2013 Terence Parr
+ All rights reserved.
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+ 1. Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in the
+    documentation and/or other materials provided with the distribution.
+ 3. The name of the author may not be used to endorse or promote products
+    derived from this software without specific prior written permission.
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/**
+derived from http://svn.r-project.org/R/trunk/src/main/gram.y
+http://cran.r-project.org/doc/manuals/R-lang.html#Parser
+I'm no R genius but this seems to work.
+Requires RFilter.g4 to strip away NL that are really whitespace,
+not end-of-command. See TestR.java
+Usage:
+$ antlr4 R.g4 RFilter.g4
+$ javac *.java
+$ java TestR sample.R
+... prints parse tree ...
+*/
+
+/*
+Modified version of the original in https://github.com/antlr/grammars-v4/blob/master/r/R.g4 so that I can separate the most relevant tokens of R in 
+the JplagRListenter.java file.
+Author of the modification: Antonio Javier Rodriguez Perez
+*/
+
+grammar R;
+
+prog:   (   expr (';'|NL)
+        |   NL
+        )*
+        EOF
+    ;
+
+/*
+expr_or_assign
+    :   expr ('<-'|'='|'<<-') expr_or_assign
+    |   expr
+    ;
+*/
+
+expr:   expr index_statement  // '[[' follows R's yacc grammar
+    |   expr access_package expr
+    |   expr ('$'|'@') expr
+    |   <assoc=right> expr '^' expr
+    |   ('-'|'+') expr
+    |   expr ':' expr
+    |   expr USER_OP expr // anything wrappedin %: '%' .* '%'
+    |   expr ('*'|'/') expr
+    |   expr ('+'|'-') expr
+    |   expr ('>'|'>='|'<'|'<='|'=='|'!=') expr
+    |   '!' expr
+    |   expr ('&'|'&&') expr
+    |   expr ('|'|'||') expr
+    |   '~' expr
+    |   expr '~' expr
+    |   expr assign_value expr
+    |   function_definition                 // define function
+    |   expr function_call                  // call function
+    |   compound_statement
+    |   if_statement
+    |   for_statement
+    |   while_statement
+    |   repeat_statement
+    |   help
+    |   next_statement
+    |   break_statement
+    |   '(' expr ')'
+    |   ID
+    |   constant
+    ;
+
+index_statement : '[[' sublist ']' ']' | '[' sublist ']' ;
+
+access_package: '::'|':::' ;
+
+function_definition: 'function' '(' formlist? ')' expr ;
+
+function_call : '(' sublist ')' ;
+
+constant: constant_number | constant_string | constant_bool | 'NULL' | 'NA' | 'Inf' | 'NaN' ;
+
+constant_number: HEX | INT | FLOAT | COMPLEX ;
+
+constant_string: STRING ;
+
+constant_bool: 'TRUE' | 'FALSE' ;
+
+help: '?' expr ; // get help on expr, usually string or ID
+
+if_statement :  'if' '(' expr ')' expr | 'if' '(' expr ')' expr 'else' expr ;
+
+for_statement : 'for' '(' ID 'in' expr ')' expr ;
+
+while_statement : 'while' '(' expr ')' expr ;
+
+repeat_statement: 'repeat' expr ;
+
+next_statement: 'next' ;
+
+break_statement: 'break' ;
+
+compound_statement: '{' exprlist '}' ;
+
+exprlist
+    :   expr ((';'|NL) expr?)*
+    |
+    ;
+
+formlist : form (',' form)* ;
+
+form:   ID
+    |   assign_func_declaration
+    ;
+
+sublist : sub (',' sub)* ;
+
+sub :   expr
+    |   assign_value_list
+    |
+    ;
+
+assign_value: '<-'|'<<-'|'='|'->'|'->>'|':=';
+
+assign_func_declaration: ID '=' expr | '...' ;
+
+assign_value_list: ID '=' | ID '=' expr | constant_string '=' | constant_string '=' expr | 'NULL' '=' | 'NULL' '=' expr | '...' ;
+
+
+
+HEX :   '0' ('x'|'X') HEXDIGIT+ [Ll]? ;
+
+INT :   DIGIT+ [Ll]? ;
+
+fragment
+HEXDIGIT : ('0'..'9'|'a'..'f'|'A'..'F') ;
+
+FLOAT:  DIGIT+ '.' DIGIT* EXP? [Ll]?
+    |   DIGIT+ EXP? [Ll]?
+    |   '.' DIGIT+ EXP? [Ll]?
+    ;
+
+fragment
+DIGIT:  '0'..'9' ;
+
+fragment
+EXP :   ('E' | 'e') ('+' | '-')? INT ;
+
+COMPLEX
+    :   INT 'i'
+    |   FLOAT 'i'
+    ;
+
+STRING
+    :   '"' ( ESC | ~[\\"] )*? '"'
+    |   '\'' ( ESC | ~[\\'] )*? '\''
+    |   '`' ( ESC | ~[\\'] )*? '`'
+    ;
+fragment
+ESC :   '\\' [abtnfrv"'\\]
+    |   UNICODE_ESCAPE
+    |   HEX_ESCAPE
+    |   OCTAL_ESCAPE
+    ;
+
+fragment
+UNICODE_ESCAPE
+    :   '\\' 'u' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT
+    |   '\\' 'u' '{' HEXDIGIT HEXDIGIT HEXDIGIT HEXDIGIT '}'
+    ;
+
+fragment
+OCTAL_ESCAPE
+    :   '\\' [0-3] [0-7] [0-7]
+    |   '\\' [0-7] [0-7]
+    |   '\\' [0-7]
+    ;
+
+fragment
+HEX_ESCAPE
+    :   '\\' HEXDIGIT HEXDIGIT?
+    ;
+
+ID  :   '.' (LETTER|'_'|'.') (LETTER|DIGIT|'_'|'.')*
+    |   LETTER (LETTER|DIGIT|'_'|'.')*
+    ;
+    
+fragment LETTER  : [a-zA-Z] ;
+
+USER_OP :   '%' .*? '%' ;
+
+COMMENT :   '#' .*? '\r'? '\n' -> type(NL) ;
+
+// Match both UNIX and Windows newlines
+NL      :   '\r'? '\n' ;
+
+WS      :   [ \t\u000C]+ -> skip ;

+ 83 - 0
jplag.frontend.rlang/src/main/antlr4/de/jplag/R/grammar/RFilter.g4

@@ -0,0 +1,83 @@
+/*
+ [The "BSD licence"]
+ Copyright (c) 2013 Terence Parr
+ All rights reserved.
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+ 1. Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in the
+    documentation and/or other materials provided with the distribution.
+ 3. The name of the author may not be used to endorse or promote products
+    derived from this software without specific prior written permission.
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/** Must process R input with this before passing to R.g4; see TestR.java
+    We strip NL inside expressions.
+ */
+
+parser grammar RFilter;
+
+options { tokenVocab=R; }
+
+@members {
+protected int curlies = 0;
+}
+
+// TODO: MAKE THIS GET ONE COMMAND ONLY
+stream : (element|NL|';')* EOF ;
+
+eat :   (NL {((WritableToken)$NL).setChannel(Token.HIDDEN_CHANNEL);})+ ;
+
+element:   op eat?
+    |   atom
+    |   '{' eat? {curlies++;} (element|NL|';')* {curlies--;} '}'
+    |   '(' (element|eat)* ')'
+    |   '[' (element|eat)* ']'
+    |   '[[' (element|eat)* ']' ']'
+    |   'function' eat? '(' (element|eat)* ')' eat?
+    |   'for' eat? '(' (element|eat)* ')' eat?
+    |   'while' eat? '(' (element|eat)* ')' eat?
+    |   'if' eat? '(' (element|eat)* ')' eat?
+    |   'else'
+        {
+        // ``inside a compound expression, a newline before else is discarded,
+        // whereas at the outermost level, the newline terminates the if
+        // construction and a subsequent else causes a syntax error.''
+        /*
+        Works here
+            if (1==0) { print(1) } else { print(2) }
+        and correctly gets error here:
+            if (1==0) { print(1) }
+            else { print(2) }
+        this works too:
+            if (1==0) {
+              if (2==0) print(1)
+              else print(2)
+            }
+        */
+        WritableToken tok = (WritableToken)_input.LT(-2);
+        if (curlies>0&&tok.getType()==NL) tok.setChannel(Token.HIDDEN_CHANNEL);
+        }
+    ;
+
+atom:   'next' | 'break' | ID | STRING | HEX | INT | FLOAT | COMPLEX | 'NULL'
+    |   'NA' | 'Inf' | 'NaN' | 'TRUE' | 'FALSE'
+    ;
+
+op  :   '+'|'-'|'*'|'/'|'^'|'<'|'<='|'>='|'>'|'=='|'!='|'&'|'&&'|USER_OP|
+        'repeat'|'in'|'?'|'!'|'='|':'|'~'|'$'|'@'|'<-'|'->'|'='|'::'|':::'|
+        ','|'...'|'||'| '|'
+    ;

+ 158 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/JplagRListener.java

@@ -0,0 +1,158 @@
+package de.jplag.R;
+
+import org.antlr.v4.runtime.Token;
+
+import de.jplag.R.grammar.*;
+
+/**
+ * Listener class for visiting the R ANTLR parse tree. Transforms the visited ANTLR token into JPlag tokens. Based on an
+ * R frontend for JPlag v2.15 by Olmo Kramer, see their
+ * <a href="https://github.com/CodeGra-de/jplag/tree/master/jplag.frontend.R">JPlag fork</a>.
+ * @author Robin Maisch
+ */
+public class JplagRListener extends RCombinedBaseListener implements RTokenConstants {
+
+    private final RParserAdapter parserAdapter;
+
+    /**
+     * Creates the listener.
+     * @param parserAdapter the JPlag parser adapter which receives the transformed tokens.
+     */
+    public JplagRListener(RParserAdapter parserAdapter) {
+        this.parserAdapter = parserAdapter;
+    }
+
+    /**
+     * Transforms an ANTLR Token into a JPlag token and transfers it to the token adapter.
+     * @param targetType the type of the JPlag token to be created.
+     * @param token the ANTLR token.
+     */
+    private void transformToken(int targetType, Token token) {
+        parserAdapter.addToken(targetType, token.getLine(), token.getCharPositionInLine() + 1, token.getText().length());
+    }
+
+    private void transformToken(int targetType, Token start, Token end) {
+        parserAdapter.addToken(targetType, start.getLine(), start.getCharPositionInLine() + 1, end.getStopIndex() - start.getStartIndex() + 1);
+    }
+
+    @Override
+    public void enterIndex_statement(RParser.Index_statementContext context) {
+        transformToken(INDEX, context.getStart(), context.getStop());
+    }
+
+    @Override
+    public void enterAccess_package(RParser.Access_packageContext context) {
+        transformToken(PACKAGE, context.getStart());
+    }
+
+    @Override
+    public void enterFunction_definition(RParser.Function_definitionContext context) {
+        transformToken(BEGIN_FUNCTION, context.getStart());
+    }
+
+    @Override
+    public void exitFunction_definition(RParser.Function_definitionContext context) {
+        transformToken(END_FUNCTION, context.getStop());
+    }
+
+    @Override
+    public void enterFunction_call(RParser.Function_callContext context) {
+        transformToken(FUNCTION_CALL, context.getStart(), context.getStop());
+    }
+
+    @Override
+    public void enterConstant_number(RParser.Constant_numberContext context) {
+        transformToken(NUMBER, context.getStart());
+    }
+
+    @Override
+    public void enterConstant_string(RParser.Constant_stringContext context) {
+        transformToken(STRING, context.getStart());
+    }
+
+    @Override
+    public void enterConstant_bool(RParser.Constant_boolContext context) {
+        transformToken(BOOL, context.getStart());
+    }
+
+    @Override
+    public void enterHelp(RParser.HelpContext context) {
+        transformToken(HELP, context.getStart());
+    }
+
+    @Override
+    public void enterIf_statement(RParser.If_statementContext context) {
+        transformToken(IF_BEGIN, context.getStart());
+    }
+
+    @Override
+    public void exitIf_statement(RParser.If_statementContext context) {
+        transformToken(IF_END, context.getStop());
+    }
+
+    @Override
+    public void enterFor_statement(RParser.For_statementContext context) {
+        transformToken(FOR_BEGIN, context.getStart());
+    }
+
+    @Override
+    public void exitFor_statement(RParser.For_statementContext context) {
+        transformToken(FOR_END, context.getStop());
+    }
+
+    @Override
+    public void enterWhile_statement(RParser.While_statementContext context) {
+        transformToken(WHILE_BEGIN, context.getStart());
+    }
+
+    @Override
+    public void exitWhile_statement(RParser.While_statementContext context) {
+        transformToken(WHILE_END, context.getStop());
+    }
+
+    @Override
+    public void enterRepeat_statement(RParser.Repeat_statementContext context) {
+        transformToken(REPEAT_BEGIN, context.getStart());
+    }
+
+    @Override
+    public void exitRepeat_statement(RParser.Repeat_statementContext context) {
+        transformToken(REPEAT_END, context.getStop());
+    }
+
+    @Override
+    public void enterNext_statement(RParser.Next_statementContext context) {
+        transformToken(NEXT, context.getStart());
+    }
+
+    @Override
+    public void enterBreak_statement(RParser.Break_statementContext context) {
+        transformToken(BREAK, context.getStart());
+    }
+
+    @Override
+    public void enterCompound_statement(RParser.Compound_statementContext context) {
+        transformToken(COMPOUND_BEGIN, context.getStart());
+    }
+
+    @Override
+    public void exitCompound_statement(RParser.Compound_statementContext context) {
+        transformToken(COMPOUND_END, context.getStop());
+    }
+
+    @Override
+    public void enterAssign_value(RParser.Assign_valueContext context) {
+        transformToken(ASSIGN, context.getStart());
+    }
+
+    @Override
+    public void enterAssign_func_declaration(RParser.Assign_func_declarationContext context) {
+        transformToken(ASSIGN_FUNC, context.getStart());
+    }
+
+    @Override
+    public void enterAssign_value_list(RParser.Assign_value_listContext context) {
+        transformToken(ASSIGN_LIST, context.getStart());
+    }
+
+}

+ 71 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/Language.java

@@ -0,0 +1,71 @@
+package de.jplag.R;
+
+import java.io.File;
+
+import de.jplag.ErrorConsumer;
+import de.jplag.TokenList;
+
+/**
+ * This represents the R language as a language supported by JPlag.
+ */
+public class Language implements de.jplag.Language {
+
+    public static final String NAME = "R Parser";
+    public static final String SHORT_NAME = "R";
+    public static final int DEFAULT_MIN_TOKEN_MATCH = 8;
+    private final RParserAdapter parserAdapter;
+
+    public Language(ErrorConsumer consumer) {
+        this.parserAdapter = new RParserAdapter(consumer);
+    }
+
+    @Override
+    public String[] suffixes() {
+        return new String[] {".R", ".r"};
+    }
+
+    @Override
+    public String getName() {
+        return NAME;
+    }
+
+    @Override
+    public String getShortName() {
+        return SHORT_NAME;
+    }
+
+    @Override
+    public int minimumTokenMatch() {
+        return DEFAULT_MIN_TOKEN_MATCH;
+    }
+
+    @Override
+    public TokenList parse(File directory, String[] files) {
+        return parserAdapter.parse(directory, files);
+    }
+
+    @Override
+    public boolean hasErrors() {
+        return parserAdapter.hasErrors();
+    }
+
+    @Override
+    public boolean supportsColumns() {
+        return true;
+    }
+
+    @Override
+    public boolean isPreformatted() {
+        return true;
+    }
+
+    @Override
+    public boolean usesIndex() {
+        return false;
+    }
+
+    @Override
+    public int numberOfTokens() {
+        return RTokenConstants.NUM_DIFF_TOKENS;
+    }
+}

+ 342 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/RCombinedBaseListener.java

@@ -0,0 +1,342 @@
+package de.jplag.R;
+
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.tree.ErrorNode;
+import org.antlr.v4.runtime.tree.TerminalNode;
+
+import de.jplag.R.grammar.*;
+
+/**
+ * Empty base implementation for {@link RListener} and {@link RFilterListener}.
+ */
+public abstract class RCombinedBaseListener implements RListener, RFilterListener {
+    @Override
+    public void enterStream(RFilter.StreamContext context) {
+
+    }
+
+    @Override
+    public void exitStream(RFilter.StreamContext context) {
+
+    }
+
+    @Override
+    public void enterEat(RFilter.EatContext context) {
+
+    }
+
+    @Override
+    public void exitEat(RFilter.EatContext context) {
+
+    }
+
+    @Override
+    public void enterElement(RFilter.ElementContext context) {
+
+    }
+
+    @Override
+    public void exitElement(RFilter.ElementContext context) {
+
+    }
+
+    @Override
+    public void enterAtom(RFilter.AtomContext context) {
+
+    }
+
+    @Override
+    public void exitAtom(RFilter.AtomContext context) {
+
+    }
+
+    @Override
+    public void enterOp(RFilter.OpContext context) {
+
+    }
+
+    @Override
+    public void exitOp(RFilter.OpContext context) {
+
+    }
+
+    @Override
+    public void enterProg(RParser.ProgContext context) {
+
+    }
+
+    @Override
+    public void exitProg(RParser.ProgContext context) {
+
+    }
+
+    @Override
+    public void enterExpr(RParser.ExprContext context) {
+
+    }
+
+    @Override
+    public void exitExpr(RParser.ExprContext context) {
+
+    }
+
+    @Override
+    public void enterIndex_statement(RParser.Index_statementContext context) {
+
+    }
+
+    @Override
+    public void exitIndex_statement(RParser.Index_statementContext context) {
+
+    }
+
+    @Override
+    public void enterAccess_package(RParser.Access_packageContext context) {
+
+    }
+
+    @Override
+    public void exitAccess_package(RParser.Access_packageContext context) {
+
+    }
+
+    @Override
+    public void enterFunction_definition(RParser.Function_definitionContext context) {
+
+    }
+
+    @Override
+    public void exitFunction_definition(RParser.Function_definitionContext context) {
+
+    }
+
+    @Override
+    public void enterFunction_call(RParser.Function_callContext context) {
+
+    }
+
+    @Override
+    public void exitFunction_call(RParser.Function_callContext context) {
+
+    }
+
+    @Override
+    public void enterConstant(RParser.ConstantContext context) {
+
+    }
+
+    @Override
+    public void exitConstant(RParser.ConstantContext context) {
+
+    }
+
+    @Override
+    public void enterConstant_number(RParser.Constant_numberContext context) {
+
+    }
+
+    @Override
+    public void exitConstant_number(RParser.Constant_numberContext context) {
+
+    }
+
+    @Override
+    public void enterConstant_string(RParser.Constant_stringContext context) {
+
+    }
+
+    @Override
+    public void exitConstant_string(RParser.Constant_stringContext context) {
+
+    }
+
+    @Override
+    public void enterConstant_bool(RParser.Constant_boolContext context) {
+
+    }
+
+    @Override
+    public void exitConstant_bool(RParser.Constant_boolContext context) {
+
+    }
+
+    @Override
+    public void enterHelp(RParser.HelpContext context) {
+
+    }
+
+    @Override
+    public void exitHelp(RParser.HelpContext context) {
+
+    }
+
+    @Override
+    public void enterIf_statement(RParser.If_statementContext context) {
+
+    }
+
+    @Override
+    public void exitIf_statement(RParser.If_statementContext context) {
+
+    }
+
+    @Override
+    public void enterFor_statement(RParser.For_statementContext context) {
+
+    }
+
+    @Override
+    public void exitFor_statement(RParser.For_statementContext context) {
+
+    }
+
+    @Override
+    public void enterWhile_statement(RParser.While_statementContext context) {
+
+    }
+
+    @Override
+    public void exitWhile_statement(RParser.While_statementContext context) {
+
+    }
+
+    @Override
+    public void enterRepeat_statement(RParser.Repeat_statementContext context) {
+
+    }
+
+    @Override
+    public void exitRepeat_statement(RParser.Repeat_statementContext context) {
+
+    }
+
+    @Override
+    public void enterNext_statement(RParser.Next_statementContext context) {
+
+    }
+
+    @Override
+    public void exitNext_statement(RParser.Next_statementContext context) {
+
+    }
+
+    @Override
+    public void enterBreak_statement(RParser.Break_statementContext context) {
+
+    }
+
+    @Override
+    public void exitBreak_statement(RParser.Break_statementContext context) {
+
+    }
+
+    @Override
+    public void enterCompound_statement(RParser.Compound_statementContext context) {
+
+    }
+
+    @Override
+    public void exitCompound_statement(RParser.Compound_statementContext context) {
+
+    }
+
+    @Override
+    public void enterExprlist(RParser.ExprlistContext context) {
+
+    }
+
+    @Override
+    public void exitExprlist(RParser.ExprlistContext context) {
+
+    }
+
+    @Override
+    public void enterFormlist(RParser.FormlistContext context) {
+
+    }
+
+    @Override
+    public void exitFormlist(RParser.FormlistContext context) {
+
+    }
+
+    @Override
+    public void enterForm(RParser.FormContext context) {
+
+    }
+
+    @Override
+    public void exitForm(RParser.FormContext context) {
+
+    }
+
+    @Override
+    public void enterSublist(RParser.SublistContext context) {
+
+    }
+
+    @Override
+    public void exitSublist(RParser.SublistContext context) {
+
+    }
+
+    @Override
+    public void enterSub(RParser.SubContext context) {
+
+    }
+
+    @Override
+    public void exitSub(RParser.SubContext context) {
+
+    }
+
+    @Override
+    public void enterAssign_value(RParser.Assign_valueContext context) {
+
+    }
+
+    @Override
+    public void exitAssign_value(RParser.Assign_valueContext context) {
+
+    }
+
+    @Override
+    public void enterAssign_func_declaration(RParser.Assign_func_declarationContext context) {
+
+    }
+
+    @Override
+    public void exitAssign_func_declaration(RParser.Assign_func_declarationContext context) {
+
+    }
+
+    @Override
+    public void enterAssign_value_list(RParser.Assign_value_listContext context) {
+
+    }
+
+    @Override
+    public void exitAssign_value_list(RParser.Assign_value_listContext context) {
+
+    }
+
+    @Override
+    public void visitTerminal(TerminalNode node) {
+
+    }
+
+    @Override
+    public void visitErrorNode(ErrorNode node) {
+
+    }
+
+    @Override
+    public void enterEveryRule(ParserRuleContext context) {
+
+    }
+
+    @Override
+    public void exitEveryRule(ParserRuleContext context) {
+
+    }
+}

+ 97 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/RParserAdapter.java

@@ -0,0 +1,97 @@
+package de.jplag.R;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.ParseTreeWalker;
+
+import de.jplag.AbstractParser;
+import de.jplag.ErrorConsumer;
+import de.jplag.R.grammar.RFilter;
+import de.jplag.R.grammar.RLexer;
+import de.jplag.R.grammar.RParser;
+import de.jplag.TokenList;
+
+/**
+ * This class sets up the lexer and parser generated by ANTLR4, feeds the submissions through them and passes the
+ * selected tokens on to the main program.
+ */
+public class RParserAdapter extends AbstractParser implements RTokenConstants {
+
+    private String currentFile;
+    private TokenList tokens;
+
+    /**
+     * Creates the RParserAdapter
+     * @param errorConsumer the ErrorConsumer that parser errors are passed on to.
+     */
+    public RParserAdapter(ErrorConsumer errorConsumer) {
+        super(errorConsumer);
+    }
+
+    /**
+     * Parsers a list of files into a single {@link TokenList}.
+     * @param directory the directory of the files.
+     * @param fileNames the file names of the files.
+     * @return a {@link TokenList} containing all tokens of all files.
+     */
+    public TokenList parse(File directory, String[] fileNames) {
+        tokens = new TokenList();
+        errors = 0;
+        for (String fileName : fileNames) {
+            if (!parseFile(directory, fileName)) {
+                errors++;
+            }
+            tokens.addToken(new RToken(FILE_END, fileName, -1, -1, -1));
+        }
+        return tokens;
+    }
+
+    private boolean parseFile(File directory, String fileName) {
+        File file = new File(directory, fileName);
+        try (FileInputStream inputStream = new FileInputStream(file)) {
+            currentFile = fileName;
+
+            // create a lexer, a parser and a buffer between them.
+            RLexer lexer = new RLexer(CharStreams.fromStream(inputStream));
+            CommonTokenStream tokens = new CommonTokenStream(lexer);
+
+            RFilter filter = new RFilter(tokens);
+            filter.stream();
+            tokens.seek(0);
+
+            RParser parser = new RParser(tokens);
+
+            // Create a tree walker and the entry context defined by the parser grammar
+            ParserRuleContext entryContext = parser.prog();
+            ParseTreeWalker treeWalker = new ParseTreeWalker();
+
+            // Walk over the parse tree:
+            for (int i = 0; i < entryContext.getChildCount(); i++) {
+                ParseTree parseTree = entryContext.getChild(i);
+                treeWalker.walk(new JplagRListener(this), parseTree);
+            }
+        } catch (IOException exception) {
+            getErrorConsumer().addError("Parsing Error in '" + fileName + "':" + File.separator + exception);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Adds a new {@link de.jplag.Token} to the current {@link TokenList}.
+     * @param type the type of the new {@link de.jplag.Token}
+     * @param line the line of the Token in the current file
+     * @param start the start column of the Token in the line
+     * @param length the length of the Token
+     */
+    /* package-private */ void addToken(int type, int line, int start, int length) {
+        tokens.addToken(new RToken(type, currentFile, line, start, length));
+
+    }
+}

+ 46 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/RToken.java

@@ -0,0 +1,46 @@
+package de.jplag.R;
+
+/**
+ * This class represents the occurrence of an R Token in the source code. Based on an R frontend for JPlag v2.15 by Olmo
+ * Kramer, see their <a href="https://github.com/CodeGra-de/jplag/tree/master/jplag.frontend.R">JPlag fork</a>.
+ */
+public class RToken extends de.jplag.Token implements RTokenConstants {
+
+    public RToken(int type, String file, int line, int column, int length) {
+        super(type, file, line, column, length);
+    }
+
+    @Override
+    public String type2string() {
+        return switch (this.type) {
+            case FILE_END -> "<EOF>";
+            case SEPARATOR_TOKEN -> "METHOD_SEPARATOR";
+            case BEGIN_FUNCTION -> "FUNCTION{";
+            case END_FUNCTION -> "}FUNCTION";
+            case FUNCTION_CALL -> "FUNCTION()";
+            case NUMBER -> "NUMBER";
+            case STRING -> "STRING";
+            case BOOL -> "BOOL";
+            case ASSIGN -> "ASSIGN";
+            case ASSIGN_FUNC -> "ASSIGN_FUNC";
+            case ASSIGN_LIST -> "ASSIGN_LIST";
+            case HELP -> "HELP";
+            case INDEX -> "INDEX";
+            case PACKAGE -> "PACKAGE";
+            case IF_BEGIN -> "IF{";
+            case IF_END -> "}IF-ELSE";
+            case FOR_BEGIN -> "FOR{";
+            case FOR_END -> "}FOR";
+            case WHILE_BEGIN -> "WHILE{";
+            case WHILE_END -> "}WHILE";
+            case REPEAT_BEGIN -> "REPEAT{";
+            case REPEAT_END -> "}REPEAT";
+            case NEXT -> "NEXT";
+            case BREAK -> "BREAK";
+            case COMPOUND_BEGIN -> "COMPOUND{";
+            case COMPOUND_END -> "}COMPOUND";
+            default -> "<UNKNOWN%d>".formatted(type);
+        };
+    }
+
+}

+ 39 - 0
jplag.frontend.rlang/src/main/java/de/jplag/R/RTokenConstants.java

@@ -0,0 +1,39 @@
+package de.jplag.R;
+
+import de.jplag.TokenConstants;
+
+/**
+ * Tokens in R that are deemed important when comparing submissions for plagiarisms. Based on an R frontend for JPlag
+ * v2.15 by Olmo Kramer, see their <a href="https://github.com/CodeGra-de/jplag/tree/master/jplag.frontend.R">JPlag
+ * fork</a>.
+ * @author Robin Maisch
+ */
+public interface RTokenConstants extends TokenConstants {
+
+    int BEGIN_FUNCTION = 2;
+    int END_FUNCTION = 3;
+    int FUNCTION_CALL = 4;
+    int NUMBER = 5;
+    int STRING = 6;
+    int BOOL = 7;
+    int ASSIGN = 8;
+    int ASSIGN_FUNC = 9;
+    int ASSIGN_LIST = 10;
+    int HELP = 11;
+    int INDEX = 12;
+    int PACKAGE = 13;
+    int IF_BEGIN = 14;
+    int IF_END = 15;
+    int FOR_BEGIN = 16;
+    int FOR_END = 17;
+    int WHILE_BEGIN = 18;
+    int WHILE_END = 19;
+    int REPEAT_BEGIN = 20;
+    int REPEAT_END = 21;
+    int NEXT = 22;
+    int BREAK = 23;
+    int COMPOUND_BEGIN = 24;
+    int COMPOUND_END = 25;
+
+    int NUM_DIFF_TOKENS = 26;
+}

+ 114 - 0
jplag.frontend.rlang/src/test/java/de/jplag/R/RFrontendTest.java

@@ -0,0 +1,114 @@
+package de.jplag.R;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.IntStream;
+import java.util.stream.StreamSupport;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import de.jplag.Token;
+import de.jplag.TokenConstants;
+import de.jplag.TokenList;
+import de.jplag.TokenPrinter;
+import de.jplag.testutils.TestErrorConsumer;
+
+public class RFrontendTest {
+
+    /**
+     * Regular expression for lines that contain no code.
+     */
+    private static final String R_NO_CODE_LINE = "\\s*(?:#.*)?";
+
+    /**
+     * Test source file that is supposed to produce a complete set of tokens, i.e. all types of tokens.
+     */
+    private static final String COMPLETE_TEST_FILE = "Complete.R";
+    public static final int NOT_SET = -1;
+
+    private final Logger logger = LoggerFactory.getLogger("R frontend test");
+    private final String[] testFiles = new String[] {"Game.R", COMPLETE_TEST_FILE};
+    private final File testFileLocation = Path.of("src", "test", "resources", "de", "jplag", "R").toFile();
+    private Language language;
+
+    @BeforeEach
+    void setup() {
+        TestErrorConsumer consumer = new TestErrorConsumer();
+        language = new Language(consumer);
+    }
+
+    @Test
+    void parseTestFiles() {
+        for (String fileName : testFiles) {
+            TokenList tokens = language.parse(testFileLocation, new String[] {fileName});
+            String output = TokenPrinter.printTokens(tokens, testFileLocation, List.of(fileName));
+            logger.info(output);
+
+            testSourceCoverage(fileName, tokens);
+            if (fileName.equals(COMPLETE_TEST_FILE))
+                testTokenCoverage(tokens, fileName);
+        }
+    }
+
+    /**
+     * Confirms that the code is covered to a basic extent, i.e. each line of code contains at least one token.
+     * @param fileName a code sample file name
+     * @param tokens the TokenList generated from the sample
+     */
+    private void testSourceCoverage(String fileName, TokenList tokens) {
+        File testFile = new File(testFileLocation, fileName);
+
+        try {
+            List<String> lines = Files.readAllLines(testFile.toPath());
+            String emptyLineExpression = getNoCodeLineExpression();
+
+            // All lines that contain code
+            var codeLines = IntStream.range(1, lines.size() + 1).filter(idx -> !lines.get(idx - 1).matches(emptyLineExpression)).toArray();
+            // All lines that contain token
+            var tokenLines = IntStream.range(0, tokens.size()).mapToObj(tokens::getToken).mapToInt(Token::getLine).distinct().toArray();
+
+            if (codeLines.length > tokenLines.length) {
+                var diffLine = IntStream.range(0, codeLines.length)
+                        .dropWhile(lineIndex -> lineIndex < tokenLines.length && codeLines[lineIndex] == tokenLines[lineIndex]).findFirst();
+                diffLine.ifPresent(
+                        lineIdx -> fail("Line %d of file '%s' is not represented in the token list.".formatted(codeLines[lineIdx], fileName)));
+            }
+            assertArrayEquals(codeLines, tokenLines);
+        } catch (IOException exception) {
+            logger.info("Error while reading test file %s".formatted(fileName), exception);
+            fail();
+        }
+    }
+
+    /**
+     * Confirms that all Token types are 'reachable' with a complete code example.
+     * @param tokens TokenList which is supposed to contain all types of tokens
+     * @param fileName The file name of the complete code example
+     */
+    private void testTokenCoverage(TokenList tokens, String fileName) {
+        var foundTokens = StreamSupport.stream(tokens.allTokens().spliterator(), true).mapToInt(Token::getType).sorted().distinct().toArray();
+        // Exclude SEPARATOR_TOKEN, as it does not occur
+        var allTokens = IntStream.range(0, RTokenConstants.NUM_DIFF_TOKENS).filter(i -> i != TokenConstants.SEPARATOR_TOKEN).toArray();
+
+        if (allTokens.length > foundTokens.length) {
+            var diffLine = IntStream.range(0, allTokens.length)
+                    .dropWhile(lineIndex -> lineIndex < foundTokens.length && allTokens[lineIndex] == foundTokens[lineIndex]).findFirst();
+            diffLine.ifPresent(lineIdx -> fail("Token type %s was not found in the complete code example '%s'."
+                    .formatted(new RToken(allTokens[lineIdx], fileName, NOT_SET, NOT_SET, NOT_SET).type2string(), fileName)));
+        }
+        assertArrayEquals(allTokens, foundTokens);
+    }
+
+    private static String getNoCodeLineExpression() {
+        return R_NO_CODE_LINE;
+    }
+
+}

+ 37 - 0
jplag.frontend.rlang/src/test/resources/de/jplag/R/Complete.R

@@ -0,0 +1,37 @@
+# This R code sample is supposed to contain the corresponding AST structure for each type of RToken.
+# It is also working code.
+# Author: Robin Maisch
+
+main <- function() {
+	sixteen <- square(4);
+	squareOkay <- ifelse(sixteen==16, TRUE, FALSE);
+	cat("Should be 16: ", sixteen, squareOkay);
+	?cat;
+
+	if (squareOkay) {
+		perfectSquare <- square
+	} else {
+		perfectSquare <- function (x) x*x
+	}
+
+	for (i in 1:10) {
+		print(apply(arg=i, func=perfectSquare))
+	}
+
+	repeat {
+		if (FALSE) next
+		else break
+	}
+
+	idx <- 0
+	while (FALSE) {
+		idx <- idx + 1
+		print((0:40)[idx=])
+	}
+}
+oneMore <- function(var) base::'+'(var, 1)  		# weird way to access + operator
+square <- function(x) x+x   						# works for x=2, so that's not bad
+identity <- function(x) x
+apply <- function(arg=0, func=identity) func(arg)
+
+main()

+ 92 - 0
jplag.frontend.rlang/src/test/resources/de/jplag/R/Game.R

@@ -0,0 +1,92 @@
+# Sample R program
+# Author: Robin Maisch
+
+readBool <- function()
+{ 
+  n <- readline(prompt="Yes or no? (y/n): ")
+  while (!grepl("[YyNn]",n))
+  {
+	 n <- readline()
+  }
+  return(ifelse(n=="y"||n=="Y", TRUE, FALSE))
+}
+
+
+getFilterBalance <- function(f, elements) {
+	return(abs(length(Filter(f, elements)) - length(elements)/2))
+}
+
+divisibleByFilter <- function(i) {
+	res <- c(function(x) x %% i == 0, paste("a multiple of", i), NA);
+	return(res)
+}
+
+greaterThanFilter <- function(i) {
+	res <- c(function(x) x > i, paste("greater than", i), NA);
+	return(res)
+}
+
+smallerThanFilter <- function(i) {
+	res <- c(function(x) x < i, paste("smaller than", i), NA);
+	return(res)
+}
+
+endsWithFilter <- function(i) {
+	res <- c(function(x) x %% 10 == i, paste("'s last digit a'", i), NA);
+	return(res)
+}
+
+# real program start here
+  
+main <- function() {
+	filters <- c(lapply(2:20, divisibleByFilter),
+				lapply(0:100, greaterThanFilter),
+				lapply(0:100, smallerThanFilter),
+				lapply(0:10, endsWithFilter))
+	filters <- aperm(simplify2array(filters, higher=FALSE), c(2,1))
+	activeFilters = list()
+
+	cat("Think of a number between 0 and 100.\n")
+	count <- 1
+	candidates <- c()
+	repeat {
+		cat("\n +++ Round",count,"+++\n\n")
+		candidates = 0:100
+		for (f in activeFilters) {
+			filterFunc = ifelse(f[[3]], function(x) f[[1]](x), function(x) !f[[1]](x))
+			candidates = Filter(filterFunc, candidates)
+		}
+		if (length(candidates) == 1) {
+			break
+		}
+
+		balance <- sapply(1:(length(filters)/3), function(i) {return(getFilterBalance(filters[i,][[1]], candidates))})
+		rank <- rank(balance, ties="first")
+		risk = max(20-2*count, 1)
+		lowerEnd = 1
+		topFiveIdx = order(rank)[lowerEnd:risk]
+		# print(filters[topFiveIdx[1:3],2])
+		winnerIdx = topFiveIdx[sample.int(risk-lowerEnd + 1,1)]
+		winnerFilter = filters[winnerIdx,]
+
+		cat("Is your number", winnerFilter[[2]], "?\n")
+		winnerFilter[[3]] <- readBool()
+
+		cat("Okay, now, let's see...\n")
+		activeFilters[[length(activeFilters)+1]] = winnerFilter
+		# cat("Now we have", length(activeFilters), "active filters.\n")
+
+		filters[- winnerIdx,] # remove element
+		count <- count + 1
+	}
+
+	cat("Your number must be", candidates[1], ", right???\n")
+	answer <- readBool()
+	if (answer) {
+		cat("Hahaha, I knew it. I'm a genius.\n")
+	} else {
+		cat("You must be joking!\n")
+	}
+
+}
+main()

+ 4 - 0
jplag/pom.xml

@@ -45,6 +45,10 @@
             <groupId>de.jplag</groupId>
             <artifactId>cpp</artifactId>
         </dependency>
+        <dependency>
+            <groupId>de.jplag</groupId>
+            <artifactId>rlang</artifactId>
+        </dependency>
         <dependency>
             <groupId>de.jplag</groupId>
             <artifactId>scheme</artifactId>

+ 14 - 5
jplag/src/main/java/de/jplag/clustering/Cluster.java

@@ -4,7 +4,6 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.function.BiFunction;
-import java.util.stream.Collectors;
 
 /**
  * Cluster part of a {@link ClusteringResult}.
@@ -15,10 +14,17 @@ public class Cluster<T> {
     private final float communityStrength;
     private final Collection<T> members;
     private ClusteringResult<T> clusteringResult = null;
+    private final float averageSimilarity;
 
-    public Cluster(Collection<T> members, float communityStrength) {
+    /**
+     * @param members Members of the cluster.
+     * @param communityStrength A metric of how strongly the members of this cluster are connected.
+     * @param averageSimilarity The average similarity between all tuple comparisons of the members in this cluster.
+     */
+    public Cluster(Collection<T> members, float communityStrength, float averageSimilarity) {
         this.members = new ArrayList<>(members);
         this.communityStrength = communityStrength;
+        this.averageSimilarity = averageSimilarity;
     }
 
     public Collection<T> getMembers() {
@@ -26,6 +32,10 @@ public class Cluster<T> {
         return members;
     }
 
+    public float getAverageSimilarity() {
+        return averageSimilarity;
+    }
+
     /**
      * See {@link ClusteringResult#getCommunityStrength}
      * @return community strength of the cluster
@@ -60,8 +70,7 @@ public class Cluster<T> {
      * @return normalized community strength per connection
      */
     public float getNormalizedCommunityStrengthPerConnection() {
-        List<Cluster<T>> goodClusters = clusteringResult.getClusters().stream().filter(cluster -> cluster.getCommunityStrength() > 0)
-                .collect(Collectors.toList());
+        List<Cluster<T>> goodClusters = clusteringResult.getClusters().stream().filter(cluster -> cluster.getCommunityStrength() > 0).toList();
         float posCommunityStrengthSum = (float) goodClusters.stream().mapToDouble(Cluster::getCommunityStrengthPerConnection).sum();
 
         int size = clusteringResult.getClusters().size();
@@ -87,7 +96,7 @@ public class Cluster<T> {
      * @param similarity function that supplies the similarity of two cluster members.
      * @return average similarity
      */
-    public float averageSimilarity(BiFunction<T, T, Float> similarity) {
+    private float averageSimilarity(BiFunction<T, T, Float> similarity) {
         List<T> members = new ArrayList<>(getMembers());
         if (members.size() < 2) {
             return 1;

+ 1 - 1
jplag/src/main/java/de/jplag/clustering/ClusteringAdapter.java

@@ -60,7 +60,7 @@ public class ClusteringAdapter {
         ClusteringResult<Integer> modularityClusterResult = ClusteringResult.fromIntegerCollections(new ArrayList<>(intResult), similarityMatrix);
         List<Cluster<Submission>> mappedClusters = modularityClusterResult.getClusters().stream()
                 .map(unmappedCluster -> new Cluster<>(unmappedCluster.getMembers().stream().map(mapping::unmap).collect(Collectors.toList()),
-                        unmappedCluster.getCommunityStrength()))
+                        unmappedCluster.getCommunityStrength(), unmappedCluster.getAverageSimilarity()))
                 .collect(Collectors.toList());
         return new ClusteringResult<>(mappedClusters, modularityClusterResult.getCommunityStrength());
     }

+ 28 - 9
jplag/src/main/java/de/jplag/clustering/ClusteringResult.java

@@ -1,12 +1,6 @@
 package de.jplag.clustering;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.function.BiFunction;
 import java.util.stream.DoubleStream;
 
@@ -20,7 +14,7 @@ import org.apache.commons.math3.linear.RealMatrix;
 public class ClusteringResult<T> {
 
     private final List<Cluster<T>> clusters;
-    private float communityStrength = 0;
+    private final float communityStrength;
 
     public ClusteringResult(Collection<Cluster<T>> clusters, float communityStrength) {
         this.clusters = List.copyOf(clusters);
@@ -92,11 +86,36 @@ public class ClusteringResult<T> {
             for (int i = 0; i < clustering.size(); i++) {
                 double outWeightSum = percentagesOfSimilaritySums.getRowVector(i).getL1Norm();
                 double clusterCommunityStrength = percentagesOfSimilaritySums.getEntry(i, i) - outWeightSum * outWeightSum;
-                clusters.add(new Cluster<>(clustering.get(i), (float) clusterCommunityStrength));
+                float averageSimilarity = calculateAverageSimilarityFor(clustering.get(i), similarity);
+                clusters.add(new Cluster<>(clustering.get(i), (float) clusterCommunityStrength, averageSimilarity));
                 communityStrength += clusterCommunityStrength;
             }
         }
         return new ClusteringResult<>(clusters, communityStrength);
     }
 
+    private static float calculateAverageSimilarityFor(Collection<Integer> cluster, RealMatrix similarityMatrix) {
+        var sumOfSimilarities = 0f;
+        List<Integer> indices = List.copyOf(cluster);
+        for (int i = 1; i < cluster.size(); i++) {
+            int indexOfSubmission1 = indices.get(i);
+            for (int j = 0; j < i; j++) { // as the similarity matrix is symmetrical we need only iterate over one half of it
+                int indexOfSubmission2 = indices.get(j);
+                sumOfSimilarities += similarityMatrix.getEntry(indexOfSubmission1, indexOfSubmission2);
+            }
+        }
+        int nMinusOne = cluster.size() - 1;
+        float numberOfComparisons = (nMinusOne * (nMinusOne + 1))
+                / 2f; /*
+                       * Use Gauss sum to calculate number of comparisons in cluster: Given cluster of size n we need Gauss sum of n-1
+                       * comparisons: compare first element of cluster to all other except itself: n-1 comparisons. compare second element to
+                       * all other except itself and first element (as these two were already compared when we processed the first element),
+                       * n-2 comparisons. compare third element to all other but itself and all previously compared: n-3 comparisons and so
+                       * on. when we reach the second to last element we have n-(n-1)=1 comparisons left. when we reach the last element it
+                       * has already been compared to all other. adding up all comparisons we get: (n-1) + (n-2) + (n-3) + ... + (n-(n-1)) =
+                       * Gauss sum of (n-1)
+                       */
+        return sumOfSimilarities / numberOfComparisons;
+    }
+
 }

+ 3 - 8
jplag/src/main/java/de/jplag/reporting/reportobject/ReportObjectFactory.java

@@ -12,6 +12,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import de.jplag.*;
+import de.jplag.reporting.reportobject.mapper.ClusteringResultMapper;
 import de.jplag.reporting.reportobject.model.*;
 import de.jplag.reporting.reportobject.model.Match;
 
@@ -21,6 +22,7 @@ import de.jplag.reporting.reportobject.model.Match;
 public class ReportObjectFactory {
 
     private static final Logger logger = LoggerFactory.getLogger(ReportObjectFactory.class);
+    private static final ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper();
 
     /**
      * Converts a JPlagResult to a JPlagReport.
@@ -59,7 +61,7 @@ public class ReportObjectFactory {
         overviewReport.setExecutionTime(result.getDuration());
         overviewReport.setComparisonNames(getComparisonNames(comparisons));
         overviewReport.setMetrics(getMetrics(result));
-        overviewReport.setClusters(getClusters(result));
+        overviewReport.setClusters(clusteringResultMapper.map(result));
 
         return overviewReport;
     }
@@ -168,13 +170,6 @@ public class ReportObjectFactory {
         return new Match(startTokenFirst.getFile(), startTokenSecond.getFile(), startFirst, endFirst, startSecond, endSecond, tokens);
     }
 
-    // TODO implement after PR Read clustering #281
-    private static List<Cluster> getClusters(JPlagResult result) {
-        // List<ClusteringResult<Submission>> clusters = result.getClusteringResult();
-        // return clusters.map( c -> new Cluster(getAvgSimilarity, getStrength, c.getMembers().map(Submission::getName)))
-        return List.of();
-    }
-
     private static List<String> readFileLines(File file) {
         List<String> lines = new ArrayList<>();
         try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file))) {

+ 26 - 0
jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapper.java

@@ -0,0 +1,26 @@
+package de.jplag.reporting.reportobject.mapper;
+
+import java.util.Collection;
+import java.util.List;
+
+import de.jplag.JPlagResult;
+import de.jplag.Submission;
+import de.jplag.clustering.ClusteringResult;
+import de.jplag.reporting.reportobject.model.Cluster;
+
+/**
+ * Extracts and maps the clusters from the JPlagResult to the corresponding JSON DTO
+ */
+public class ClusteringResultMapper {
+    public List<Cluster> map(JPlagResult result) {
+        var clusteringResult = result.getClusteringResult();
+        return clusteringResult.stream().map(ClusteringResult::getClusters).flatMap(Collection::stream).map(this::convertCluster).toList();
+    }
+
+    private Cluster convertCluster(de.jplag.clustering.Cluster<Submission> from) {
+        var strength = from.getCommunityStrength();
+        var avgSimilarity = from.getAverageSimilarity();
+        var member = from.getMembers().stream().map(Submission::getName).toList();
+        return new Cluster(avgSimilarity, strength, member);
+    }
+}

+ 2 - 28
jplag/src/main/java/de/jplag/reporting/reportobject/model/Cluster.java

@@ -4,32 +4,6 @@ import java.util.List;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
-public class Cluster {
-
-    @JsonProperty("average_similarity")
-    private final float averageSimilarity;
-
-    @JsonProperty("strength")
-    private final float strength;
-
-    @JsonProperty("members")
-    private final List<String> members;
-
-    public Cluster(float averageSimilarity, float strength, List<String> members) {
-        this.averageSimilarity = averageSimilarity;
-        this.strength = strength;
-        this.members = List.copyOf(members);
-    }
-
-    public float getAverageSimilarity() {
-        return averageSimilarity;
-    }
-
-    public float getStrength() {
-        return strength;
-    }
-
-    public List<String> getMembers() {
-        return members;
-    }
+public record Cluster(@JsonProperty("average_similarity") float averageSimilarity, @JsonProperty("strength") float strength,
+        @JsonProperty("members") List<String> members) {
 }

+ 4 - 13
jplag/src/test/java/de/jplag/clustering/ClusterTest.java

@@ -13,36 +13,27 @@ public class ClusterTest {
     private static final double EPSILON = 0.00001;
     Cluster<Character> cluster;
 
-    @Test
-    public void testAverageSimilarity() {
-        cluster = new Cluster<>(List.of('a', 'b', 'c'), 0);
-        float averageSimilarity = cluster.averageSimilarity((a, b) -> {
-            return Math.abs((float) (((int) a) - ((int) b)));
-        });
-        assertEquals((1.f + 2.f + 1.f + 1.f + 2.f + 1.f) / 6, averageSimilarity, EPSILON);
-    }
-
     @Test
     public void testCommunityStrengthPerConnectionOneMember() {
-        cluster = new Cluster<>(List.of('a'), 10);
+        cluster = new Cluster<>(List.of('a'), 10, 0);
         assertEquals(0.0, cluster.getCommunityStrengthPerConnection(), EPSILON);
     }
 
     @Test
     public void testCommunityStrengthPerConnectionTwoMembers() {
-        cluster = new Cluster<>(List.of('a', 'b'), 10);
+        cluster = new Cluster<>(List.of('a', 'b'), 10, 0);
         assertEquals(10.0, cluster.getCommunityStrengthPerConnection(), EPSILON);
     }
 
     @Test
     public void testCommunityStrengthPerConnectionThreeMembers() {
-        cluster = new Cluster<>(List.of('a', 'b', 'c'), 10);
+        cluster = new Cluster<>(List.of('a', 'b', 'c'), 10, 0);
         assertEquals(10.0 / 3, cluster.getCommunityStrengthPerConnection(), EPSILON);
     }
 
     @Test
     public void testNormalizedCommunityStrength() {
-        cluster = new Cluster<>(List.of('a', 'b', 'c'), 10);
+        cluster = new Cluster<>(List.of('a', 'b', 'c'), 10, 0);
         @SuppressWarnings("unchecked")
         ClusteringResult<Character> clusteringResult = mock(ClusteringResult.class);
         when(clusteringResult.getClusters()).thenReturn(List.of(cluster, cluster));

+ 0 - 215
jplag/src/test/java/de/jplag/clustering/ClusteringRealDataTest.java

@@ -1,215 +0,0 @@
-package de.jplag.clustering;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assumptions.assumeTrue;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.net.URISyntaxException;
-import java.net.URL;
-import java.util.*;
-import java.util.stream.Collectors;
-
-import org.apache.commons.math3.linear.Array2DRowRealMatrix;
-import org.apache.commons.math3.linear.RealMatrix;
-import org.junit.jupiter.api.Test;
-
-import de.jplag.clustering.algorithm.GenericClusteringAlgorithm;
-import de.jplag.clustering.algorithm.SpectralClustering;
-import de.jplag.clustering.preprocessors.CumulativeDistributionFunctionPreprocessor;
-
-/**
- * These test are not meant to be run during normal unit testing. They can be used to test the clustering algorithms
- * against data from the private pseudomized reports repository. These tests test PROBABILISTIC behavior, so use with
- * caution!
- */
-public class ClusteringRealDataTest {
-
-    private static class TestFile {
-
-        private String uri;
-        private Optional<List<String>> expected;
-
-        public TestFile(String uri, Optional<List<String>> expected) {
-            this.uri = uri;
-            this.expected = expected;
-        }
-    }
-
-    private static List<String> B_POSITIVE = Arrays.asList(new String[] {"Student (31)", "Student (223)"});
-    private static List<String> C_POSITIVE = Arrays.asList(new String[] {"Student (166)", "Student (212)", "Student (236)", "Student (229)"});
-
-    private static final TestFile[] OLD_CLUSTERING_DATA = {new TestFile("de/jplag/PseudonymizedReports/alt/A_1000_matches_max.csv", Optional.empty()),
-            new TestFile("de/jplag/PseudonymizedReports/alt/B_1000_matches_max.csv", Optional.of(B_POSITIVE)),
-            new TestFile("de/jplag/PseudonymizedReports/alt/C_1000_matches_max.csv", Optional.of(C_POSITIVE)),};
-
-    private static final TestFile[] NEW_CLUSTERING_DATA = {new TestFile("de/jplag/PseudonymizedReports/neu/A_matches_avg.csv", Optional.empty()),
-            new TestFile("de/jplag/PseudonymizedReports/neu/B_matches_avg.csv", Optional.of(B_POSITIVE)),
-            new TestFile("de/jplag/PseudonymizedReports/neu/C_matches_avg.csv", Optional.of(C_POSITIVE)),};
-
-    private String str(float f) {
-        return String.format("%.4f", f);
-    }
-
-    private URL loadFromClasspath(String file) throws FileNotFoundException {
-        URL url = getClass().getClassLoader().getResource(file);
-        if (url == null) {
-            assumeTrue(false, file + " not found. 'de/jpag/PseudonymizedReports' must contain the data from the PseudonymizedReports repository.");
-        }
-        return url;
-    }
-
-    private void doTesting(ReadResult readResult, Optional<List<String>> expected) {
-        RealMatrix clusteringSimilarity = new Array2DRowRealMatrix(readResult.similarity.getData());
-
-        /*
-         * AgglomerativeClustering.ClusteringOptions options = new AgglomerativeClustering.ClusteringOptions();
-         * options.minimalSimilarity = 0.15f; options.similarity = AgglomerativeClustering.InterClusterSimilarity.AVERAGE;
-         * ClusteringAlgorithm clusteringAlg = new AgglomerativeClustering(options);
-         */
-
-        SpectralClustering clusteringAlg = new SpectralClustering(ClusteringOptions.DEFAULTS);
-        ClusteringPreprocessor preprocessor = new CumulativeDistributionFunctionPreprocessor();
-        GenericClusteringAlgorithm preprocessedClusteringAlg = new PreprocessedClusteringAlgorithm(clusteringAlg, preprocessor);
-        Collection<Collection<Integer>> clustering = preprocessedClusteringAlg.cluster(clusteringSimilarity);
-        ClusteringResult<Integer> mRes = ClusteringResult.fromIntegerCollections(new ArrayList<>(clustering), readResult.similarity);
-        List<Cluster<Integer>> clusters = new ArrayList<>(mRes.getClusters());
-        clusters.sort(Comparator.comparingDouble(c -> -c.getNormalizedCommunityStrengthPerConnection()));
-
-        System.out.println("cs\tncsm\tavgSim\tcombined\tmembers");
-        for (Cluster<Integer> c : clusters) {
-            float ncsm = c.getCommunityStrengthPerConnection();
-            float avgSim = c.averageSimilarity((a, b) -> (float) readResult.similarity.getEntry(a, b));
-            System.out.println(str(c.getCommunityStrength()) + "\t" + str(ncsm) + "\t" + str(avgSim) + "\t"
-                    + c.getMembers().stream().map(readResult.mapping::unmap).collect(Collectors.toList()));
-        }
-        System.out.println("Community Strength: " + mRes.getCommunityStrength());
-        System.out.println("Clusters: " + clusters.size());
-
-        expected.ifPresent(expectedIdentifiers -> {
-            Set<String> expectedIdentifiersSet = new HashSet<>(expectedIdentifiers);
-            Set<String> bestClusters = clusters.get(0).getMembers().stream().map(readResult.mapping::unmap).collect(Collectors.toSet());
-            assertEquals(expectedIdentifiersSet, bestClusters);
-            System.out.println("hey");
-        });
-    }
-
-    @Test
-    public void aClusteringOld() throws FileNotFoundException, URISyntaxException {
-        for (TestFile testFile : OLD_CLUSTERING_DATA) {
-            URL url = loadFromClasspath(testFile.uri);
-            File file = new File(url.toURI());
-            ReadResult r = readOldCsv(file);
-            doTesting(r, testFile.expected);
-        }
-    }
-
-    @Test
-    public void aClusteringNew() throws FileNotFoundException, URISyntaxException {
-        for (TestFile testFile : NEW_CLUSTERING_DATA) {
-            URL url = loadFromClasspath(testFile.uri);
-            File file = new File(url.toURI());
-            ReadResult r = readNewCsv(file);
-            doTesting(r, testFile.expected);
-        }
-    }
-
-    private static class ReadComparison {
-        int left;
-        int right;
-        float similarity;
-    }
-
-    private static class ReadResult {
-        IntegerMapping<String> mapping;
-        RealMatrix similarity;
-    }
-
-    private static ReadResult readNewCsv(File fileName) throws FileNotFoundException {
-        IntegerMapping<String> mapping = new IntegerMapping<>(512);
-        List<ReadComparison> comparisons = new ArrayList<>(512);
-        try (CSVReader reader = new CSVReader(fileName, ";")) {
-            while (reader.hasNext()) {
-                List<String> records = reader.next();
-                if (records.isEmpty())
-                    continue;
-                String leftStudent = records.get(1);
-                String rightStudent = records.get(2);
-                String similarity = records.get(3);
-                ReadComparison comparison = new ReadComparison();
-                comparison.left = mapping.map(leftStudent);
-                comparison.right = mapping.map(rightStudent);
-                comparison.similarity = Float.parseFloat(similarity) / 100;
-                comparisons.add(comparison);
-            }
-        }
-        RealMatrix matrix = new Array2DRowRealMatrix(mapping.size(), mapping.size());
-        for (ReadComparison comparison : comparisons) {
-            matrix.setEntry(comparison.left, comparison.right, comparison.similarity);
-            matrix.setEntry(comparison.right, comparison.left, comparison.similarity);
-        }
-        ReadResult r = new ReadResult();
-        r.similarity = matrix;
-        r.mapping = mapping;
-        return r;
-    }
-
-    private static ReadResult readOldCsv(File fileName) throws FileNotFoundException {
-        IntegerMapping<String> mapping = new IntegerMapping<>(512);
-        List<ReadComparison> comparisons = new ArrayList<>(512);
-        try (CSVReader reader = new CSVReader(fileName, ";")) {
-            while (reader.hasNext()) {
-                List<String> records = reader.next();
-                if (records.isEmpty())
-                    continue;
-                Deque<String> stuff = new ArrayDeque<>(records);
-                String leftStudent = stuff.removeFirst();
-                int leftID = mapping.map(leftStudent);
-                while (stuff.size() >= 3) {
-                    ReadComparison comparison = new ReadComparison();
-                    comparison.left = leftID;
-                    stuff.removeFirst(); // comparison ID not needed
-                    String rightStudent = stuff.removeFirst();
-                    String similarity = stuff.removeFirst();
-                    comparison.right = mapping.map(rightStudent);
-                    comparison.similarity = Float.parseFloat(similarity) / 100;
-                    comparisons.add(comparison);
-                }
-            }
-        }
-        RealMatrix matrix = new Array2DRowRealMatrix(mapping.size(), mapping.size());
-        for (ReadComparison comparison : comparisons) {
-            matrix.setEntry(comparison.left, comparison.right, comparison.similarity);
-            matrix.setEntry(comparison.right, comparison.left, comparison.similarity);
-        }
-        ReadResult r = new ReadResult();
-        r.similarity = matrix;
-        r.mapping = mapping;
-        return r;
-    }
-
-    private static class CSVReader implements AutoCloseable {
-        private String delimiter;
-        private Scanner scanner;
-
-        private CSVReader(File fileName, String delimiter) throws FileNotFoundException {
-            this.delimiter = delimiter;
-            scanner = new Scanner(fileName);
-        }
-
-        @Override
-        public void close() {
-            scanner.close();
-        }
-
-        List<String> next() {
-            String line = scanner.nextLine();
-            String[] records = line.split(delimiter);
-            return Arrays.asList(records);
-        }
-
-        boolean hasNext() {
-            return scanner.hasNextLine();
-        }
-    }
-}

+ 59 - 3
jplag/src/test/java/de/jplag/clustering/ClusteringResultTest.java

@@ -2,6 +2,7 @@ package de.jplag.clustering;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import org.apache.commons.math3.linear.Array2DRowRealMatrix;
@@ -49,8 +50,63 @@ class ClusteringResultTest {
         assertEquals(0.0, result.getCommunityStrength(), 0.00001);
     }
 
-    private static void setEntries(RealMatrix matrix, int i, int j, double value) {
-        matrix.setEntry(i, j, value);
-        matrix.setEntry(j, i, value);
+    @Test
+    void averageSimilarity() {
+        var similarity = new Array2DRowRealMatrix(4, 4);
+
+        setEntries(similarity, 0, 1, 0.5);
+        setEntries(similarity, 0, 2, 0.3);
+        setEntries(similarity, 0, 3, 0.4);
+        setEntries(similarity, 1, 2, 0.1);
+        setEntries(similarity, 1, 3, 0.1);
+        setEntries(similarity, 2, 3, 0.7);
+
+        ClusteringResult<Integer> result = ClusteringResult.fromIntegerCollections(List.of(List.of(0, 1, 2, 3)), similarity);
+
+        assertEquals(0.35, result.getClusters().stream().findFirst().orElseThrow().getAverageSimilarity(), 0.00001);
+    }
+
+    @Test
+    void averageSimilarity2() {
+        var similarity = new Array2DRowRealMatrix(6, 6);
+
+        setEntries(similarity, 0, 1, 0.5);
+        setEntries(similarity, 0, 2, 0.3);
+        setEntries(similarity, 0, 3, 0.4);
+        setEntries(similarity, 0, 4, 0.4);
+        setEntries(similarity, 1, 2, 0.1);
+        setEntries(similarity, 1, 3, 0.1);
+        setEntries(similarity, 1, 4, 0.3);
+        setEntries(similarity, 2, 3, 0.7);
+        setEntries(similarity, 2, 4, 0.2);
+        setEntries(similarity, 2, 5, 0.9);
+        setEntries(similarity, 3, 4, 0.5);
+        setEntries(similarity, 3, 5, 0.05);
+
+        ClusteringResult<Integer> result = ClusteringResult.fromIntegerCollections(List.of(List.of(0, 1, 4), List.of(2, 3, 5)), similarity);
+        var clusters = new ArrayList<>(result.getClusters());
+
+        assertEquals(0.4, clusters.get(0).getAverageSimilarity(), 0.00001);
+        assertEquals(0.55, clusters.get(1).getAverageSimilarity(), 0.00001);
+    }
+
+    @Test
+    void averageSimilarityPerfectClustering() {
+        RealMatrix similarity = new Array2DRowRealMatrix(4, 4);
+
+        // These are similar
+        setEntries(similarity, 0, 1, 1f);
+        setEntries(similarity, 2, 3, 1f);
+
+        // Others are dissimilar
+
+        ClusteringResult<Integer> result = ClusteringResult.fromIntegerCollections(List.of(List.of(0, 1), List.of(2, 3)), similarity);
+        var cluster = result.getClusters().stream().findFirst().orElseThrow();
+        assertEquals(1f, cluster.getAverageSimilarity(), 0.00001);
+    }
+
+    private static void setEntries(RealMatrix matrix, int i, int j, double similarity) {
+        matrix.setEntry(i, j, similarity);
+        matrix.setEntry(j, i, similarity);
     }
 }

+ 48 - 0
jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapperTest.java

@@ -0,0 +1,48 @@
+package de.jplag.reporting.reportobject.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import de.jplag.JPlagResult;
+import de.jplag.Submission;
+import de.jplag.clustering.Cluster;
+import de.jplag.clustering.ClusteringResult;
+
+public class ClusteringResultMapperTest {
+    private final ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper();
+
+    @Test
+    public void test() {
+        // given
+        JPlagResult resultMock = mock(JPlagResult.class);
+        Cluster<Submission> cluster1 = createClusterWith(0.2f, 0.4f, "1", "2");
+        Cluster<Submission> cluster2 = createClusterWith(0.3f, 0.6f, "3", "4", "5");
+        when(resultMock.getClusteringResult()).thenReturn(List.of(new ClusteringResult<>(List.of(cluster1, cluster2), 0.3f)));
+
+        // when
+        var result = clusteringResultMapper.map(resultMock);
+
+        // then
+        assertEquals(List.of(new de.jplag.reporting.reportobject.model.Cluster(0.4f, 0.2f, List.of("1", "2")),
+                new de.jplag.reporting.reportobject.model.Cluster(0.6f, 0.3f, List.of("3", "4", "5"))
+
+        ), result);
+    }
+
+    private Cluster<Submission> createClusterWith(Float communityStrength, Float averageSimilarity, String... ids) {
+        var submissions = Arrays.stream(ids).map(this::submissionWithId).toList();
+        return new Cluster<>(submissions, communityStrength, averageSimilarity);
+    }
+
+    private Submission submissionWithId(String id) {
+        Submission submission = mock(Submission.class);
+        when(submission.getName()).thenReturn(id);
+        return submission;
+    }
+}

+ 6 - 0
pom.xml

@@ -49,6 +49,7 @@
         <module>jplag.frontend.csharp-6</module>
         <module>jplag.frontend.java</module>
         <module>jplag.frontend.python-3</module>
+        <module>jplag.frontend.rlang</module>
         <module>jplag.frontend.scheme</module>
         <module>jplag.frontend.text</module>
         <module>jplag</module>
@@ -156,6 +157,11 @@
                 <artifactId>csharp-6</artifactId>
                 <version>${project.version}</version>
             </dependency>
+            <dependency>
+                <groupId>${project.groupId}</groupId>
+                <artifactId>rlang</artifactId>
+                <version>${project.version}</version>
+            </dependency>
             <dependency>
                 <groupId>${project.groupId}</groupId>
                 <artifactId>cpp</artifactId>