Browse Source

Merge branch 'master-remote' into golang

# Conflicts:
#	jplag/pom.xml
#	jplag/src/main/java/de/jplag/options/LanguageOption.java
#	jplag/src/test/java/de/jplag/special/TokenPrinterTest.java
Robin Maisch 4 years ago
parent
commit
52176ac7ef
37 changed files with 1993 additions and 602 deletions
  1. 13 0
      .github/workflows/maven.yml
  2. 1 1
      .github/workflows/publish.yml
  3. 14 1
      .github/workflows/report-viewer-test.yml
  4. 1 1
      .github/workflows/report-viewer.yml
  5. 13 0
      .github/workflows/spotless.yml
  6. 75 20
      jplag.frontend-utils/src/main/java/de/jplag/TokenPrinter.java
  7. 16 0
      jplag.frontend.rlang/README.md
  8. 45 0
      jplag.frontend.rlang/pom.xml
  9. 216 0
      jplag.frontend.rlang/src/main/antlr4/de/jplag/rlang/grammar/R.g4
  10. 83 0
      jplag.frontend.rlang/src/main/antlr4/de/jplag/rlang/grammar/RFilter.g4
  11. 158 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/JplagRListener.java
  12. 71 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/Language.java
  13. 342 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/RCombinedBaseListener.java
  14. 97 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/RParserAdapter.java
  15. 46 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/RToken.java
  16. 39 0
      jplag.frontend.rlang/src/main/java/de/jplag/rlang/RTokenConstants.java
  17. 114 0
      jplag.frontend.rlang/src/test/java/de/jplag/rlang/RFrontendTest.java
  18. 37 0
      jplag.frontend.rlang/src/test/resources/de/jplag/rlang/Complete.R
  19. 92 0
      jplag.frontend.rlang/src/test/resources/de/jplag/rlang/Game.R
  20. 5 1
      jplag/pom.xml
  21. 1 1
      jplag/src/main/java/de/jplag/ErrorCollector.java
  22. 14 5
      jplag/src/main/java/de/jplag/clustering/Cluster.java
  23. 1 1
      jplag/src/main/java/de/jplag/clustering/ClusteringAdapter.java
  24. 28 9
      jplag/src/main/java/de/jplag/clustering/ClusteringResult.java
  25. 2 1
      jplag/src/main/java/de/jplag/options/LanguageOption.java
  26. 3 3
      jplag/src/main/java/de/jplag/reporting/JsonReport.java
  27. 7 10
      jplag/src/main/java/de/jplag/reporting/reportobject/ReportObjectFactory.java
  28. 26 0
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapper.java
  29. 2 28
      jplag/src/main/java/de/jplag/reporting/reportobject/model/Cluster.java
  30. 4 13
      jplag/src/test/java/de/jplag/clustering/ClusterTest.java
  31. 0 215
      jplag/src/test/java/de/jplag/clustering/ClusteringRealDataTest.java
  32. 59 3
      jplag/src/test/java/de/jplag/clustering/ClusteringResultTest.java
  33. 48 0
      jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapperTest.java
  34. 9 0
      jplag/src/test/java/de/jplag/special/TokenPrinterTest.java
  35. 9 3
      pom.xml
  36. 284 268
      report-viewer/package-lock.json
  37. 18 18
      report-viewer/package.json

+ 13 - 0
.github/workflows/maven.yml

@@ -9,7 +9,20 @@ on:
   workflow_dispatch:
 
 jobs:
+  pre_job:
+    runs-on: ubuntu-latest
+    outputs:
+      should_skip: ${{ steps.skip_check.outputs.should_skip }}
+    steps:
+      - id: skip_check
+        uses: fkirc/skip-duplicate-actions@master
+        with:
+          concurrent_skipping: 'same_content_newer'
+          skip_after_successful_duplicate: 'true'
+
   build:
+    needs: pre_job
+    if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
     runs-on: ubuntu-latest
 
     steps:

+ 1 - 1
.github/workflows/publish.yml

@@ -18,7 +18,7 @@ jobs:
         with:
           servers: '[{ "id": "ossrh", "username": "jplag", "password": "${{ secrets.OSSRH_TOKEN }}" }]'
       - name: Import GPG key
-        uses: crazy-max/ghaction-import-gpg@v4
+        uses: crazy-max/ghaction-import-gpg@v5
         with:
           gpg_private_key: ${{ secrets.PGP_SECRET }}
           passphrase: ${{ secrets.PGP_PW }}

+ 14 - 1
.github/workflows/report-viewer-test.yml

@@ -6,7 +6,20 @@ on:
     types: [opened, synchronize, reopened]
       
 jobs:
-  build-and-deploy:
+  pre_job:
+    runs-on: ubuntu-latest
+    outputs:
+      should_skip: ${{ steps.skip_check.outputs.should_skip }}
+    steps:
+      - id: skip_check
+        uses: fkirc/skip-duplicate-actions@master
+        with:
+          concurrent_skipping: 'same_content_newer'
+          skip_after_successful_duplicate: 'true'
+
+  build:
+    needs: pre_job
+    if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
     runs-on: ubuntu-latest
     steps:
       - name: Checkout 🛎️

+ 1 - 1
.github/workflows/report-viewer.yml

@@ -24,7 +24,7 @@ jobs:
           npm run build
 
       - name: Deploy 🚀
-        uses: JamesIves/github-pages-deploy-action@v4.3.3
+        uses: JamesIves/github-pages-deploy-action@v4.3.4
         with:
           branch: gh-pages
           folder: report-viewer/dist

+ 13 - 0
.github/workflows/spotless.yml

@@ -9,7 +9,20 @@ on:
   workflow_dispatch:
 
 jobs:
+  pre_job:
+    runs-on: ubuntu-latest
+    outputs:
+      should_skip: ${{ steps.skip_check.outputs.should_skip }}
+    steps:
+      - id: skip_check
+        uses: fkirc/skip-duplicate-actions@master
+        with:
+          concurrent_skipping: 'same_content_newer'
+          skip_after_successful_duplicate: 'true'
+
   build:
+    needs: pre_job
+    if: ${{ needs.pre_job.outputs.should_skip != 'true' && github.actor != 'dependabot[bot]' }}
     runs-on: ubuntu-latest
 
     steps:

+ 75 - 20
jplag.frontend-utils/src/main/java/de/jplag/TokenPrinter.java

@@ -28,7 +28,10 @@ public final class TokenPrinter {
     private static final String BAR = "|";
     private static final String TAB = "\t";
     private static final String SPACE = " ";
-    private static final String TAB_REPLACEMENT = "        "; // might depend on files
+    public static final String NON_WHITESPACE = "\\S";
+    public static final int MIN_PADDING = 1;
+    private static final int TAB_LENGTH = 8;
+    private static final String TAB_REPLACEMENT = SPACE.repeat(TAB_LENGTH); // might depend on files
 
     // Configuration:
     private static final boolean INDICATE_TINY_TOKEN = true; // print token with length <= 1 in lowercase
@@ -62,6 +65,7 @@ public final class TokenPrinter {
         int lineIndex = 0;
         int columnIndex = 1;
         String file = null; // no file yet
+        String currentLine = null;
 
         for (Token token : tokens.allTokens()) {
             // New code file:
@@ -80,24 +84,22 @@ public final class TokenPrinter {
                 columnIndex = 1;
 
                 String fileName = token.getFile().isEmpty() ? root.getName() : token.getFile();
-                String currentLine = filesToLines.get(fileName).get(lineIndex - 1);
-                if (REPLACE_TABS) {
-                    currentLine = currentLine.replace(TAB, TAB_REPLACEMENT);
-                }
-                appendCodeLinePrefix(builder, lineIndex).append(currentLine);
-                appendTokenLinePrefix(builder);
+                currentLine = filesToLines.get(fileName).get(lineIndex - 1);
+
+                appendCodeLinePrefix(builder, lineIndex);
+                appendCodeLine(builder, currentLine);
+                appendTokenLinePrefix(builder, lineIndex);
             }
 
+            assert currentLine != null;
+
             // New line if already past token index:
             if (columnIndex > token.getColumn()) {
-                appendTokenLinePrefix(builder);
+                appendTokenLinePrefix(builder, lineIndex);
                 columnIndex = 1;
             }
             // Move to token index:
-            while (columnIndex < token.getColumn()) {
-                builder.append(SPACE);
-                columnIndex++;
-            }
+            columnIndex = appendPadding(builder, currentLine, columnIndex, token.getColumn());
 
             // Print the actual token:
             String stringRepresentation = getStringRepresentation(token);
@@ -106,10 +108,8 @@ public final class TokenPrinter {
 
             // Move up to token end:
             int tokenEndIndex = token.getColumn() + token.getLength() - 1;
-            while (columnIndex < tokenEndIndex) {
-                builder.append(SPACE);
-                columnIndex++;
-            }
+            columnIndex = appendPadding(builder, currentLine, columnIndex, tokenEndIndex);
+
             // Print token end if not already past it:
             if (columnIndex == tokenEndIndex) {
                 builder.append(BAR);
@@ -119,12 +119,55 @@ public final class TokenPrinter {
         return builder.toString();
     }
 
+    /**
+     * Appends the code line to the StringBuilder, applying the configuration of this TokenPrinter.
+     * @param builder The StringBuilder to add code to
+     * @param currentLine The line of code to append
+     * @return a reference to that same StringBuilder
+     */
+    private static StringBuilder appendCodeLine(StringBuilder builder, String currentLine) {
+        if (REPLACE_TABS) {
+            currentLine = currentLine.replaceAll(TAB, TAB_REPLACEMENT);
+        }
+        return builder.append(currentLine);
+    }
+
+    /**
+     * Appends whitespace padding to the given StringBuilder in order to reach the targetPosition. Note that <b>the indices
+     * are 1-based</b>, whereas the positions in currentLine are 0-based. The convention that lines start at position 1
+     * comes from the frontends, specifically the Java frontend.
+     * @param builder The StringBuilder to add padding to
+     * @param currentLine The current line in the code file, indicating where it containes tab characters
+     * @param currentPosition The (1-based) index of the last character within the currentLine that is labeled, or 1.
+     * @param targetPosition The (1-based) index of the next character within the currentLine that should be labeled
+     * @return the new position, which is the targetPosition or the end of the line.
+     */
+    private static int appendPadding(StringBuilder builder, String currentLine, int currentPosition, int targetPosition) {
+        targetPosition = Math.min(targetPosition, currentLine.length());
+        if (currentPosition >= targetPosition) {
+            return currentPosition;
+        }
+
+        // The replacement operation preserves TAB characters, which is essential for correct alignment
+        String padding = currentLine.substring(currentPosition - 1, targetPosition - 1).replaceAll(TAB, REPLACE_TABS ? TAB_REPLACEMENT : TAB)
+                .replaceAll(NON_WHITESPACE, SPACE);
+        builder.append(padding);
+
+        return targetPosition;
+    }
+
     private static StringBuilder appendCodeLinePrefix(StringBuilder builder, int index) {
-        return builder.append(System.lineSeparator()).append(index + TAB);
+        builder.append(System.lineSeparator()).append(index);
+
+        // Code line should be padded up to at least one tab length, and no less than MIN_PADDING
+        int paddingLength = Math.max(TAB_LENGTH - digitCount(index), MIN_PADDING);
+        return builder.append(SPACE.repeat(paddingLength));
     }
 
-    private static StringBuilder appendTokenLinePrefix(StringBuilder builder) {
-        return builder.append(System.lineSeparator()).append(TAB);
+    private static StringBuilder appendTokenLinePrefix(StringBuilder builder, int index) {
+        int paddingLength = Math.max(digitCount(index) + MIN_PADDING, TAB_LENGTH);
+        String padding = SPACE.repeat(paddingLength);
+        return builder.append(System.lineSeparator()).append(padding);
     }
 
     /**
@@ -138,7 +181,7 @@ public final class TokenPrinter {
      * Turns a list of files into a map which maps the file names to a list of the contained lines of that file.
      */
     private static Map<String, List<String>> readFiles(Collection<File> allFiles) {
-        return allFiles.stream().collect(toMap(it -> it.getName(), it -> linesFromFile(it)));
+        return allFiles.stream().collect(toMap(File::getName, TokenPrinter::linesFromFile));
     }
 
     /**
@@ -152,4 +195,16 @@ public final class TokenPrinter {
         }
         return Collections.emptyList();
     }
+
+    /**
+     * Returns the number of digits (including a minus) of the given number.
+     */
+    private static int digitCount(int number) {
+        if (number == 0) {
+            return 1;
+        }
+        int minusLength = number < 0 ? 1 : 0;
+        // The 'log10' variant is supposedly faster than the 'toString' variant.
+        return (int) Math.log10(Math.abs(number)) + minusLength + 1;
+    }
 }

+ 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/rlang/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/rlang/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/rlang/JplagRListener.java

@@ -0,0 +1,158 @@
+package de.jplag.rlang;
+
+import org.antlr.v4.runtime.Token;
+
+import de.jplag.rlang.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/rlang/Language.java

@@ -0,0 +1,71 @@
+package de.jplag.rlang;
+
+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/rlang/RCombinedBaseListener.java

@@ -0,0 +1,342 @@
+package de.jplag.rlang;
+
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.tree.ErrorNode;
+import org.antlr.v4.runtime.tree.TerminalNode;
+
+import de.jplag.rlang.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/rlang/RParserAdapter.java

@@ -0,0 +1,97 @@
+package de.jplag.rlang;
+
+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.TokenList;
+import de.jplag.rlang.grammar.RFilter;
+import de.jplag.rlang.grammar.RLexer;
+import de.jplag.rlang.grammar.RParser;
+
+/**
+ * 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/rlang/RToken.java

@@ -0,0 +1,46 @@
+package de.jplag.rlang;
+
+/**
+ * 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/rlang/RTokenConstants.java

@@ -0,0 +1,39 @@
+package de.jplag.rlang;
+
+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/rlang/RFrontendTest.java

@@ -0,0 +1,114 @@
+package de.jplag.rlang;
+
+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", "rlang").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/rlang/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/rlang/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()

+ 5 - 1
jplag/pom.xml

@@ -14,7 +14,7 @@
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-databind</artifactId>
-            <version>2.13.2.2</version>
+            <version>2.13.3</version>
         </dependency>
         <dependency>
             <groupId>org.apache.commons</groupId>
@@ -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>golang</artifactId>

+ 1 - 1
jplag/src/main/java/de/jplag/ErrorCollector.java

@@ -44,7 +44,7 @@ public class ErrorCollector implements ErrorConsumer {
             logger.info(message);
         }
         if (longMessage != null && verbosity == LONG) {
-            logger.info(message);
+            logger.info(longMessage);
         }
     }
 

+ 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;
+    }
+
 }

+ 2 - 1
jplag/src/main/java/de/jplag/options/LanguageOption.java

@@ -13,6 +13,7 @@ public enum LanguageOption {
     PYTHON_3("python3", "de.jplag.python3.Language"),
     C_CPP("cpp", "de.jplag.cpp.Language"),
     C_SHARP("csharp", "de.jplag.csharp.Language"),
+    R_LANG("rlang", "de.jplag.rlang.Language"),
     GO("go", "de.jplag.go.Language"),
     CHAR("char", "de.jplag.chars.Language"),
     TEXT("text", "de.jplag.text.Language"),
@@ -35,7 +36,7 @@ public enum LanguageOption {
     }
 
     public static LanguageOption fromDisplayName(String displayName) {
-        return Arrays.stream(LanguageOption.values()).filter(languageOption -> languageOption.displayName.equals(displayName)).findFirst()
+        return Arrays.stream(LanguageOption.values()).filter(languageOption -> languageOption.displayName.equalsIgnoreCase(displayName)).findFirst()
                 .orElse(getDefault());
     }
 

+ 3 - 3
jplag/src/main/java/de/jplag/reporting/JsonReport.java

@@ -24,9 +24,9 @@ public class JsonReport implements Report {
     @Override
     public boolean saveReport(JPlagResult result, String path) {
         JPlagReport report = ReportObjectFactory.getReportObject(result);
-        File dir = new File(path);
-        if (!dir.exists()) {
-            if (!dir.mkdir()) {
+        File directory = new File(path);
+        if (!directory.exists()) {
+            if (!directory.mkdirs()) {
                 logger.error("Failed to create dir.");
             }
         }

+ 7 - 10
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.
@@ -36,7 +38,8 @@ public class ReportObjectFactory {
      * Generates an Overview DTO of a JPlagResult.
      */
     private static OverviewReport generateOverviewReport(JPlagResult result) {
-        List<JPlagComparison> comparisons = result.getComparisons();
+        int numberOfComparisons = result.getOptions().getMaximumNumberOfComparisons();
+        List<JPlagComparison> comparisons = result.getComparisons(numberOfComparisons);
         OverviewReport overviewReport = new OverviewReport();
 
         // TODO: Consider to treat entries that were checked differently from old entries with prior work.
@@ -58,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;
     }
@@ -69,7 +72,8 @@ public class ReportObjectFactory {
      */
     private static List<ComparisonReport> generateComparisonReports(JPlagResult result) {
         List<ComparisonReport> comparisons = new ArrayList<>();
-        result.getComparisons().forEach(comparison -> comparisons.add( //
+        int numberOfComparisons = result.getOptions().getMaximumNumberOfComparisons();
+        result.getComparisons(numberOfComparisons).forEach(comparison -> comparisons.add( //
                 new ComparisonReport(comparison.getFirstSubmission().getName(), //
                         comparison.getSecondSubmission().getName(), //
                         comparison.similarity(), //
@@ -166,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;
+    }
+}

+ 9 - 0
jplag/src/test/java/de/jplag/special/TokenPrinterTest.java

@@ -43,6 +43,15 @@ public class TokenPrinterTest extends TestBase {
         });
     }
 
+    @Disabled
+    @Test
+    public void printRLangFiles() {
+        printSubmissions(options -> {
+            options.setLanguageOption(LanguageOption.R_LANG);
+            options.setMinimumTokenMatch(MIN_TOKEN_MATCH); // for printing also allow small files
+        });
+    }
+
     @Disabled
     @Test
     public void printGoFiles() {

+ 9 - 3
pom.xml

@@ -50,6 +50,7 @@
         <module>jplag.frontend.golang</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>
@@ -84,7 +85,7 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <maven.compiler.source>17</maven.compiler.source>
         <maven.compiler.target>17</maven.compiler.target>
-        <spotless.version>2.22.5</spotless.version>
+        <spotless.version>2.22.8</spotless.version>
         <slf4j.version>1.7.36</slf4j.version>
         <!-- The Revision of JPlag -->
         <revision>4.0.0-SNAPSHOT</revision>
@@ -157,6 +158,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>
@@ -211,7 +217,7 @@
         <dependency>
             <groupId>org.mockito</groupId>
             <artifactId>mockito-core</artifactId>
-            <version>4.5.1</version>
+            <version>4.6.1</version>
             <scope>test</scope>
         </dependency>
         <dependency>
@@ -344,7 +350,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
-                <version>3.0.0-M6</version>
+                <version>3.0.0-M7</version>
             </plugin>
             <plugin>
                 <groupId>org.codehaus.mojo</groupId>

File diff suppressed because it is too large
+ 284 - 268
report-viewer/package-lock.json


+ 18 - 18
report-viewer/package.json

@@ -9,36 +9,36 @@
   },
   "dependencies": {
     "@highlightjs/vue-plugin": "^2.1.0",
-    "chart.js": "^3.7.1",
+    "chart.js": "^3.8.0",
     "chartjs-plugin-datalabels": "^2.0.0",
-    "core-js": "^3.22.5",
-    "gitart-vue-dialog": "^2.0.5",
+    "core-js": "^3.23.3",
+    "gitart-vue-dialog": "^2.3.1",
     "highlight.js": "^11.5.1",
-    "jszip": "^3.9.1",
-    "vue": "^3.2.33",
+    "jszip": "^3.10.0",
+    "vue": "^3.2.37",
     "vue-chart-3": "^3.1.8",
     "vue-draggable-next": "^2.1.1",
-    "vue-router": "^4.0.15",
+    "vue-router": "^4.0.16",
     "vue3-highlightjs": "^1.0.5",
     "vuex": "^4.0.2"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.23.0",
-    "@typescript-eslint/parser": "^5.23.0",
-    "@vue/cli-plugin-babel": "~5.0.4",
-    "@vue/cli-plugin-eslint": "~5.0.4",
+    "@typescript-eslint/eslint-plugin": "^5.30.0",
+    "@typescript-eslint/parser": "^5.30.0",
+    "@vue/cli-plugin-babel": "~5.0.6",
+    "@vue/cli-plugin-eslint": "~5.0.6",
     "@vue/cli-plugin-router": "~5.0.1",
-    "@vue/cli-plugin-typescript": "~5.0.4",
-    "@vue/cli-service": "~5.0.4",
-    "@vue/compiler-sfc": "^3.2.33",
+    "@vue/cli-plugin-typescript": "~5.0.6",
+    "@vue/cli-service": "~5.0.6",
+    "@vue/compiler-sfc": "^3.2.37",
     "@vue/eslint-config-prettier": "^7.0.0",
     "@vue/eslint-config-typescript": "^10.0.0",
-    "eslint": "^8.15.0",
-    "eslint-plugin-prettier": "^4.0.0",
+    "eslint": "^8.18.0",
+    "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-vue": "^8.7.1",
-    "lint-staged": "^12.4.1",
-    "prettier": "^2.6.2",
-    "typescript": "~4.6.4"
+    "lint-staged": "^13.0.3",
+    "prettier": "^2.7.1",
+    "typescript": "~4.7.4"
   },
   "gitHooks": {
     "pre-commit": "lint-staged"

Some files were not shown because too many files changed in this diff