diff --git a/src/helpers.nim b/src/helpers.nim index 4edd10f4..203d849b 100644 --- a/src/helpers.nim +++ b/src/helpers.nim @@ -16,3 +16,9 @@ proc getSortedSubdirs*(dir: string): seq[string] = if kind == pcDir: result.add path sort result + +template writeError*(description: string, details: string) = + stdout.styledWriteLine(fgRed, description & ":") + stdout.writeLine(details) + stdout.write "\n" + result = false diff --git a/src/lint/concept_exercises.nim b/src/lint/concept_exercises.nim new file mode 100644 index 00000000..1d55ca1a --- /dev/null +++ b/src/lint/concept_exercises.nim @@ -0,0 +1,42 @@ +import std/[json, os, terminal] +import ".."/helpers +import "."/validators + +proc isValidAuthorOrContributor(data: JsonNode, context: string, path: string): bool = + if isObject(data, context, path): + result = true + checkString("github_username") + checkString("exercism_username", isRequired = false) + +template checkFiles(data: JsonNode, context, path: string) = + if hasObject(data, context, path): + checkArrayOfStrings(context, "solution") + checkArrayOfStrings(context, "test") + checkArrayOfStrings(context, "exemplar") + else: + result = false + +proc isValidConceptExerciseConfig(data: JsonNode, path: string): bool = + if isObject(data, "root", path): + result = true + checkArrayOf("authors", isValidAuthorOrContributor) + checkArrayOf("contributors", isValidAuthorOrContributor, isRequired = false) + checkFiles(data, "files", path) + checkArrayOfStrings("", "forked_from", isRequired = false) + checkString("language_versions", isRequired = false) + +proc isEveryConceptExerciseConfigValid*(trackDir: string): bool = + let conceptExercisesDir = trackDir / "exercises" / "concept" + result = true + if dirExists(conceptExercisesDir): + for exerciseDir in getSortedSubdirs(conceptExercisesDir): + let configPath = exerciseDir / ".meta" / "config.json" + if fileExists(configPath): + let j = + try: + parseFile(configPath) + except: + writeError("JSON parsing error", getCurrentExceptionMsg()) + continue + if not isValidConceptExerciseConfig(j, configPath): + result = false diff --git a/src/lint/lint.nim b/src/lint/lint.nim index 74c765b6..3c4a5898 100644 --- a/src/lint/lint.nim +++ b/src/lint/lint.nim @@ -1,11 +1,6 @@ import std/[json, os, terminal] import ".."/[cli, helpers] - -template writeError(description: string, details: string) = - stdout.styledWriteLine(fgRed, description & ":") - stdout.writeLine(details) - stdout.write "\n" - result = false +import "."/concept_exercises proc isValidTrackConfig(trackDir: string): bool = result = true @@ -68,12 +63,14 @@ proc lint*(conf: Conf) = let b1 = isValidTrackConfig(trackDir) let b2 = conceptExerciseFilesExist(trackDir) let b3 = conceptFilesExist(trackDir) + let b4 = isEveryConceptExerciseConfigValid(trackDir) - if b1 and b2 and b3: + if b1 and b2 and b3 and b4: echo """ Basic linting finished successfully: - config.json exists and is valid JSON +- Every concept has the required .md files and links.json file - Every concept exercise has the required .md files and a .meta/config.json file -- Every concept has the required .md files and links.json file""" +- Every concept exercise .meta/config.json file is valid""" else: quit(1) diff --git a/src/lint/validators.nim b/src/lint/validators.nim new file mode 100644 index 00000000..2541b400 --- /dev/null +++ b/src/lint/validators.nim @@ -0,0 +1,78 @@ +import std/[json, strutils, terminal] +import ".."/helpers +export strutils.strip + +proc q(s: string): string = + "'" & s & "'" + +proc isObject*(data: JsonNode; context, path: string): bool = + result = true + if data.kind != JObject: + writeError("Not an object: " & q(context), path) + +proc hasObject*(data: JsonNode; key, path: string, + isRequired = true): bool = + result = true + if data.hasKey(key): + if data[key].kind != JObject: + writeError("Not an object: " & q(key), path) + elif isRequired: + writeError("Missing key: " & q(key), path) + +template checkString*(key: string, isRequired = true) = + if data.hasKey(key): + if data[key].kind == JString: + let s = data[key].getStr() + if s.len == 0: + writeError("String is zero-length: " & q(key), path) + elif s.strip().len == 0: + writeError("String is whitespace-only: " & q(key), path) + else: + writeError("Not a string: " & q(key) & ": " & $data[key], path) + elif isRequired: + writeError("Missing key: " & q(key), path) + +proc format(context, key: string): string = + if context.len > 0: + q(context & "." & key) + else: + q(key) + +template checkArrayOfStrings*(context, key: string; isRequired = true) = + var d = if context.len == 0: data else: data[context] + if d.hasKey(key): + if d[key].kind == JArray: + if d[key].len == 0: + if isRequired: + writeError("Array is empty: " & format(context, key), path) + else: + for item in d[key]: + if item.kind == JString: + let s = item.getStr() + if s.len == 0: + writeError("Array contains zero-length string: " & format(context, key), path) + elif s.strip().len == 0: + writeError("Array contains whitespace-only string: " & q(key), path) + else: + writeError("Array contains non-string: " & format(context, key) & ": " & $item, path) + else: + writeError("Not an array: " & format(context, key), path) + elif isRequired: + writeError("Missing key: " & format(context, key), path) + +template checkArrayOf*(key: string, + call: proc(d: JsonNode; key, path: string): bool, + isRequired = true) = + if data.hasKey(key): + if data[key].kind == JArray: + if data[key].len == 0: + if isRequired: + writeError("Array is empty: " & q(key), path) + else: + for item in data[key]: + if not call(item, key, path): + result = false + else: + writeError("Not an array: " & q(key), path) + elif isRequired: + writeError("Missing key: " & q(key), path)