commit cc86e6916544a198ccd6059faa45167ce9deeb2f Author: Kalle Struik Date: Sun Jun 5 00:09:54 2022 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c4ec2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +# IDE +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..0490fbf --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# C# Comment Checker +A utility to verify the presence and style of C# method and inline comments. + +## Building +```sh +./gradlew build +``` +After completion the artifact will be located in build/libs + +## Usage +```sh +java -jar CSharpCommentChecker.jar path/one path/two path/three path/... +``` + +## Credits +Based on [CSharpCommentValidator](https://github.com/jellejurre/CSharpCommentValidator/) by [@JelleJurre](https://github.com/jellejurre/) on GitHub. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7b373d7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,39 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.6.20" +} + +group = "nl.kallestruik" +version = "1.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.4") + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +tasks.withType { + kotlinOptions.jvmTarget = "16" +} + +tasks.withType() { + manifest { + attributes( + "Main-Class" to "MainKt" + ) + } + + duplicatesStrategy = DuplicatesStrategy.INCLUDE + + configurations["compileClasspath"].forEach { file: File -> + from(zipTree(file.absoluteFile)) + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e6e589 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..3da45c1 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright ? 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ?$var?, ?${var}?, ?${var:-default}?, ?${var+SET}?, +# ?${var#prefix}?, ?${var%suffix}?, and ?$( cmd )?; +# * compound commands having a testable exit status, especially ?case?; +# * various built-in commands including ?command?, ?set?, and ?ulimit?. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..27e6f7a --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "CSharpCommentChecker" + diff --git a/src/main/kotlin/CC.kt b/src/main/kotlin/CC.kt new file mode 100644 index 0000000..c7ee53c --- /dev/null +++ b/src/main/kotlin/CC.kt @@ -0,0 +1,76 @@ +@file:Suppress("unused") + +object CC { + // Reset + const val RESET = "\u001b[0m" // Text Reset + + // Regular Colors + const val BLACK = "\u001b[0;30m" // BLACK + const val RED = "\u001b[0;31m" // RED + const val GREEN = "\u001b[0;32m" // GREEN + const val YELLOW = "\u001b[0;33m" // YELLOW + const val BLUE = "\u001b[0;34m" // BLUE + const val PURPLE = "\u001b[0;35m" // PURPLE + const val CYAN = "\u001b[0;36m" // CYAN + const val WHITE = "\u001b[0;37m" // WHITE + + // Bold + const val BLACK_BOLD = "\u001b[1;30m" // BLACK + const val RED_BOLD = "\u001b[1;31m" // RED + const val GREEN_BOLD = "\u001b[1;32m" // GREEN + const val YELLOW_BOLD = "\u001b[1;33m" // YELLOW + const val BLUE_BOLD = "\u001b[1;34m" // BLUE + const val PURPLE_BOLD = "\u001b[1;35m" // PURPLE + const val CYAN_BOLD = "\u001b[1;36m" // CYAN + const val WHITE_BOLD = "\u001b[1;37m" // WHITE + + // Underline + const val BLACK_UNDERLINED = "\u001b[4;30m" // BLACK + const val RED_UNDERLINED = "\u001b[4;31m" // RED + const val GREEN_UNDERLINED = "\u001b[4;32m" // GREEN + const val YELLOW_UNDERLINED = "\u001b[4;33m" // YELLOW + const val BLUE_UNDERLINED = "\u001b[4;34m" // BLUE + const val PURPLE_UNDERLINED = "\u001b[4;35m" // PURPLE + const val CYAN_UNDERLINED = "\u001b[4;36m" // CYAN + const val WHITE_UNDERLINED = "\u001b[4;37m" // WHITE + + // Background + const val BLACK_BACKGROUND = "\u001b[40m" // BLACK + const val RED_BACKGROUND = "\u001b[41m" // RED + const val GREEN_BACKGROUND = "\u001b[42m" // GREEN + const val YELLOW_BACKGROUND = "\u001b[43m" // YELLOW + const val BLUE_BACKGROUND = "\u001b[44m" // BLUE + const val PURPLE_BACKGROUND = "\u001b[45m" // PURPLE + const val CYAN_BACKGROUND = "\u001b[46m" // CYAN + const val WHITE_BACKGROUND = "\u001b[47m" // WHITE + + // High Intensity + const val BLACK_BRIGHT = "\u001b[0;90m" // BLACK + const val RED_BRIGHT = "\u001b[0;91m" // RED + const val GREEN_BRIGHT = "\u001b[0;92m" // GREEN + const val YELLOW_BRIGHT = "\u001b[0;93m" // YELLOW + const val BLUE_BRIGHT = "\u001b[0;94m" // BLUE + const val PURPLE_BRIGHT = "\u001b[0;95m" // PURPLE + const val CYAN_BRIGHT = "\u001b[0;96m" // CYAN + const val WHITE_BRIGHT = "\u001b[0;97m" // WHITE + + // Bold High Intensity + const val BLACK_BOLD_BRIGHT = "\u001b[1;90m" // BLACK + const val RED_BOLD_BRIGHT = "\u001b[1;91m" // RED + const val GREEN_BOLD_BRIGHT = "\u001b[1;92m" // GREEN + const val YELLOW_BOLD_BRIGHT = "\u001b[1;93m" // YELLOW + const val BLUE_BOLD_BRIGHT = "\u001b[1;94m" // BLUE + const val PURPLE_BOLD_BRIGHT = "\u001b[1;95m" // PURPLE + const val CYAN_BOLD_BRIGHT = "\u001b[1;96m" // CYAN + const val WHITE_BOLD_BRIGHT = "\u001b[1;97m" // WHITE + + // High Intensity backgrounds + const val BLACK_BACKGROUND_BRIGHT = "\u001b[0;100m" // BLACK + const val RED_BACKGROUND_BRIGHT = "\u001b[0;101m" // RED + const val GREEN_BACKGROUND_BRIGHT = "\u001b[0;102m" // GREEN + const val YELLOW_BACKGROUND_BRIGHT = "\u001b[0;103m" // YELLOW + const val BLUE_BACKGROUND_BRIGHT = "\u001b[0;104m" // BLUE + const val PURPLE_BACKGROUND_BRIGHT = "\u001b[0;105m" // PURPLE + const val CYAN_BACKGROUND_BRIGHT = "\u001b[0;106m" // CYAN + const val WHITE_BACKGROUND_BRIGHT = "\u001b[0;107m" // WHITE +} \ No newline at end of file diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 0000000..3e2dcfe --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,343 @@ +import kotlinx.cli.* +import java.io.File + +// Pre setup some regexes. +val methodRegex = Regex("""(?public|private|internal|protected)\s(async\s)?((static|virtual|abstract|override|new)\s)?(async\s)?(?[a-zA-Z0-9<>]*)\s(?[A-Za-z_0-9<>, ]*)\((?[A-Za-z_0-9\[\]<>, ]*)\)""") +val methodArgumentRegex = Regex("""((this|out|ref|in) )?(?([a-zA-Z0-9]+(\[,*])? [a-zA-Z0-9]+)|([a-zA-Z0-9]+<([a-zA-Z0-9<>]+(\[,*])?(, )?)*>(\[,*])? ?[a-zA-Z0-9]+))""") +val methodCommentArgumentRegex = Regex("""(?.*)""") + +/** + * Entry point of the application. + * @param args The command line arguments. + */ +fun main(args: Array) { + // Parse command line arguments. + val parser = ArgParser("CSharpCommentChecker") + val folders by parser.argument(ArgType.String, description = "Folders to check").vararg() + parser.parse(args) + + // Find all files that should be checked. + val files = folders + .flatMap { File(it).walkTopDown() } + .filter { it.isFile && it.extension == "cs" } + + // Check every file and collect their issues. + val issues = files + .flatMap(::checkFile) + + // Print all issues to stdout. + issues.map(Issue::toPrintable).forEach(::println) + + // If there were issues exit with a non-zero status code. + if (issues.isNotEmpty()) + kotlin.system.exitProcess(1) +} + + +/** + * Check the given file for issues and return a these issues if any in a list. + * @param fileToCheck The file to check for issues. + * @return A list of [Issue] objects each representing exactly one issue that exists in the file. + */ +fun checkFile(fileToCheck: File): List { + // Check both method and inline comments on the file and combine their issues. + return checkMethodComments(fileToCheck) + + checkInlineComments(fileToCheck) +} + +/** + * Check the given file for issues with the inline comments. + * @param fileToCheck The file to check for issues. + * @return A list of [Issue] objects each representing exactly one issue that exists in the file with regard to + * inline comments. + */ +fun checkInlineComments(fileToCheck: File): List { + // Read all lines in the file and trim any extra whitespace from them. + val fileLines = fileToCheck.readLines() + .map { it.trim() } + + // Find all lines that include a comment and are not a method comment. + val commentLines = fileLines + .mapIndexedNotNull { i, content -> if (content.startsWith("//") && !content.startsWith("///")) i else null } + + // Group the found comments into blocks and process each block into its own string. + val commentBlocks = groupCommentLines(commentLines) + .map {section -> section + .map { it to fileLines[it] } + .map { it.first to it.second.removePrefix("//") } + .map { it.first to it.second.trim() } + .reduce { acc, it -> acc.first to "${acc.second}\n${it.second}" } + } + + // Check for issues in each block and return the list of issues that are found. + return commentBlocks + .filterNot { "" in it.second } + .filter { !it.second.first().isUpperCase() || !it.second.endsWith(".") } + .map { Issue.inlineComment(fileToCheck, it.first) } +} + +/** + * Find groups of integers that follow each other without gaps to create comment blocks. + * @param commentLines The list of integers to group. + * @return A list of lists containing integers. Each list represents a grouping. + */ +fun groupCommentLines(commentLines: List): List> { + var currentLine = commentLines.firstOrNull() ?: return emptyList() + + val groups = mutableListOf>() + var currentGroup = mutableListOf(currentLine) + + // Loop through every integer in the given list except for the first since that is already processed above. + commentLines.drop(1).forEach { line -> + // If the lasted checked one is one lower than this one + if (currentLine + 1 == line) + // Add this line to the current group. + currentGroup += line + else { + // Otherwise, store the current group and start a new one with this as the first entry. + groups += currentGroup + currentGroup = mutableListOf(line) + } + + // Finally, update the current line to this one. + currentLine = line + } + + // If the current group isn't empty store it with the rest of the groups. + if (currentGroup.isNotEmpty()) + groups += currentGroup + + // And return the list of all groups. + return groups +} + +/** + * Check the given file for issues with the method comments. + * @param fileToCheck The file to check for issues. + * @return A list of [Issue] objects each representing exactly one issue that exists in the file with regard to + * method comments. + */ +fun checkMethodComments(fileToCheck: File): List { + // Find all lines that contain methods in the file. + val methodLines = fileToCheck + .readLines() + .map { it.trim() } + .mapIndexedNotNull { i, content -> if (content.matches(methodRegex)) i else null } + + // Check each of these methods for issues and combine those into a single list to return. + return methodLines + .flatMap { checkMethod(fileToCheck, it) } +} + +/** + * Check the given method for issues with regard to its method comment. + * @param file The file that the method resides in. + * @param methodLineNumber The line that the method starts at. + * @return A list of [Issue] objects each representing exactly one issue with the method comment of this method. + */ +fun checkMethod(file: File, methodLineNumber: Int): List { + // Read all lines in the file and trim any extra whitespace from them. + val lines = file + .readLines() + .map(String::trim) + + val methodLine = lines[methodLineNumber] + + // Record constructors are exempt from rules. + if (methodLine.contains("record")) + return emptyList() + + // Find the return type, method name, and arguments of the method. + val match = methodRegex.find(methodLine) ?: return emptyList() + val (_, _, _, _, _, returnType, methodName, arguments) = match.destructured + + // Find all lines that contain the method comment and join them together with new lines. + // Start one above the method because that is where comments start. + var currentLineNumber = methodLineNumber - 1 + val commentLines = mutableListOf() + while (lines[currentLineNumber].isMethodComment()) { + commentLines += lines[currentLineNumber].removePrefix("///") + currentLineNumber-- + } + val comment = commentLines.reversed().joinToString("\n") + + // If a block is marked as exempt we don't need to check it. + if ("" in comment) + return emptyList() + + // Check the summary, arguments, and return comments for issues and combine them into a single list if they exist. + return checkMethodSummary(file, methodLineNumber, methodName, comment) + + checkMethodArguments(file, methodLineNumber, methodName, comment, arguments) + + checkMethodReturn(file, methodLineNumber, methodName, comment, returnType) +} + +/** + * Check for issues with the method summary comment. + * @param file The file that the method resides in. + * @param methodLine The line number of the method. + * @param comment The string containing the full method comment. + * @return A list containing either one or zero [Issue] objects in case there is a problem with the method summary. + */ +fun checkMethodSummary(file: File, methodLine: Int, methodName: String, comment: String): List = when { + "" !in comment || "" !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "does not have a ${CC.CYAN}summary")) + """\s+[A-Z]""".toRegex() !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "has a summary that doesn't start with a space and a capital letter")) + """\.\s+""".toRegex() !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "has a summary that doesn't end with a dot and a space")) + else -> emptyList() +} + +/** + * Check for issues with the method argument comments. + * @param file The file that the method resides in. + * @param methodLine The line number of the method. + * @param comment The string containing the full method comment. + * @param arguments The string containing all the method arguments. + * @return A list containing exactly one [Issue] object for each issue with the method argument comments. + */ +fun checkMethodArguments(file: File, methodLine: Int, methodName: String, comment: String, arguments: String): List { + if (arguments.isEmpty()) return emptyList() + + // Find all individual arguments and their names. + val argumentNames = methodArgumentRegex.findAll(arguments) + .mapNotNull { it.groups["argument"]?.value } + .filter { it.isNotBlank() } + .map { it.substringAfterLast(' ') } + .toList() + + // Find all arguments in the comments. + val commentArguments = methodCommentArgumentRegex + .findAll(comment) + .map { it.groups } + + // Split the found comment arguments into lists of names and descriptions. + val (commentArgumentNames, commentArgumentDescriptions) = commentArguments + .map { Pair(it["name"]?.value ?: "", it["description"]?.value ?: "") } + .unzip() + + // Find missing argument comments. + val missingArgumentIssues = (argumentNames - commentArgumentNames.toSet()) + .map { Issue.missingMethodArgumentComment(file, methodLine, methodName, it) } + + // Find redundant argument comments. + val extraArgumentIssues = (commentArgumentNames - argumentNames.toSet()) + .map { Issue.extraMethodArgumentComment(file, methodLine, methodName, it) } + + // Find invalid argument comments. + val invalidArgumentDescriptionIssues = commentArgumentNames + .mapIndexed { i, name -> + if (commentArgumentDescriptions[i].isValidArgumentDescription()) + null + else + Issue.invalidMethodArgumentComment(file, methodLine, methodName, name) + }.filterNotNull() + + // Combine all issues and return them. + return missingArgumentIssues + extraArgumentIssues + invalidArgumentDescriptionIssues +} + +/** + * Check for issues with the method return comment. + * @param file The file that the method resides in. + * @param methodLine The line number of the method. + * @param comment The string containing the full method comment. + * @param returnType The return type of the method. + * @return A list containing either one or zero [Issue] objects in case there is a problem with the method summary. + */ +fun checkMethodReturn(file: File, methodLine: Int, methodName: String, comment: String, returnType: String): List { + if (returnType == "void") + return emptyList() + + return when { + "" !in comment || "" !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "does not have a ${CC.CYAN}return comment")) + """\s+[A-Z]""".toRegex() !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "has a return comment that doesn't start with a space and a capital letter")) + """\.\s+""".toRegex() !in comment -> listOf(Issue.methodComment(file, methodLine, methodName, "has a return comment that doesn't end with a dot and a space")) + else -> emptyList() + } +} + +/** + * Detect whether the string is valid as part of a method comment. + * @return True if it is, false otherwise. + */ +private fun String.isMethodComment(): Boolean { + // Allow both method comment lines and annotations to be counted as method comment lines. + return this.startsWith("///") || this.startsWith("[") +} + +/** + * Check whether the string is a valid argument description. + * @return True if it is, false otherwise. + */ +private fun String.isValidArgumentDescription(): Boolean { + // Check if the string starts with a space and a capital letter and ends with a dot and a space. + return """^\s+[A-Z].*\.\s+$""".toRegex() matches this +} + +data class Issue( + val file: File, + val line: Int, + val description: String +) { + /** + * Convert the issue into a printable string representation. + * @return The printable string representation. + */ + fun toPrintable(): String { + return "$description${CC.RESET}\n" + + "File: ${file.parent}${File.separator}${CC.BLUE}${file.name}:${CC.GREEN}${line + 1}${CC.RESET}\n" + } + + companion object { + /** + * Create an [Issue] object for a method comment issue. + * @param file The file that the issue is in. + * @param line The line number of the issue. + * @param method The name of the method that the issue is related to. + * @param description A description of the issue. + * @return The issue that was created. + */ + fun methodComment(file: File, line: Int, method: String, description: String): Issue = + Issue(file, line, "${CC.YELLOW}Method $method ${CC.RESET}$description") + + /** + * Create an [Issue] object for a missing method argument comment. + * @param file The file that the issue is in. + * @param line The line number of the issue. + * @param method The name of the method that the issue is related to. + * @param argument The argument that is missing. + * @return The issue that was created. + */ + fun missingMethodArgumentComment(file: File, line: Int, method: String, argument: String): Issue = + methodComment(file, line, method, "does not have a parameter comment for ${CC.CYAN}parameter $argument") + + /** + * Create an [Issue] object for a redundant method argument comment. + * @param file The file that the issue is in. + * @param line The line number of the issue. + * @param method The name of the method that the issue is related to. + * @param argument The argument that is redundant. + * @return The issue that was created. + */ + fun extraMethodArgumentComment(file: File, line: Int, method: String, argument: String): Issue = + methodComment(file, line, method, "has a redundant parameter comment for ${CC.CYAN}parameter $argument") + + /** + * Create an [Issue] object for an invalid method argument comment. + * @param file The file that the issue is in. + * @param line The line number of the issue. + * @param method The name of the method that the issue is related to. + * @param argument The argument whose that is invalid. + * @return The issue that was created. + */ + fun invalidMethodArgumentComment(file: File, line: Int, method: String, argument: String): Issue = + methodComment(file, line, method, "has an invalid parameter comment for ${CC.CYAN}parameter $argument") + + /** + * Create an [Issue] object for an invalid inline comment. + * @param file The file that the issue is in. + * @param line The line number of the issue. + * @return The issue that was created. + */ + fun inlineComment(file: File, line: Int): Issue = + Issue(file, line, "${CC.YELLOW}Comment in ${file.name}${CC.RESET} is invalid") + } +} \ No newline at end of file