Skip to content

Commit

Permalink
lint: add initial checks for approaches and articles (#704)
Browse files Browse the repository at this point in the history
We haven't yet finished formalizing the spec for an approach or article,
but there's an open PR for that [1].

[1] exercism/docs#406
  • Loading branch information
ee7 authored Nov 11, 2022
1 parent 9a528b9 commit 8907279
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 3 deletions.
1 change: 1 addition & 0 deletions src/helpers.nim
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ proc dirExists*(path: Path): bool {.borrow.}
proc fileExists*(path: Path): bool {.borrow.}
proc readFile*(path: Path): string {.borrow.}
proc writeFile*(path: Path; content: string) {.borrow.}
proc parentDir*(path: Path): string {.borrow.}

func toLineAndCol(s: string; offset: Natural): tuple[line: int; col: int] =
## Returns the line and column number corresponding to the `offset` in `s`.
Expand Down
146 changes: 146 additions & 0 deletions src/lint/approaches_and_articles.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import std/[json, os, strformat, strutils]
import ".."/helpers
import "."/validators

type
DirKind = enum
dkApproaches = ".approaches"
dkArticles = ".articles"

proc setFalseIfFileMissingOrEmpty(b: var bool, path: Path, msgMissing: string) =
if fileExists(path):
if path.readFile().len == 0:
let msg = &"The below file is empty"
b.setFalseAndPrint(msg, path)
else:
b.setFalseAndPrint(msgMissing, path)

proc hasValidIntroduction(data: JsonNode, path: Path): bool =
const k = "introduction"
if hasObject(data, k, path, isRequired = false):
let d = data[k]
let checks = [
hasArrayOfStrings(d, "authors", path, k, uniqueValues = true),
hasArrayOfStrings(d, "contributors", path, k, isRequired = false),
]
result = allTrue(checks)
if data.kind == JObject and data.hasKey(k):
let introductionPath = Path(path.parentDir() / "introduction.md")
let msg = &"The config.json '{k}' object is present, but there is no " &
"corresponding introduction file at the below location"
result.setFalseIfFileMissingOrEmpty(introductionPath, msg)

proc isValidApproachOrArticle(data: JsonNode, context: string,
path: Path): bool =
if isObject(data, context, path):
let checks = [
hasString(data, "uuid", path, context, checkIsUuid = true),
hasString(data, "slug", path, context, checkIsKebab = true),
hasString(data, "title", path, context, maxLen = 255),
hasString(data, "blurb", path, context, maxLen = 280),
hasArrayOfStrings(data, "authors", path, context, uniqueValues = true),
hasArrayOfStrings(data, "contributors", path, context,
isRequired = false),
]
result = allTrue(checks)
if result:
let slug = data["slug"].getStr()
let dkDir = path.parentDir()
let slugDir = Path(dkDir / slug)
if not dirExists(slugDir):
let msg = &"A config.json '{context}.slug' value is '{slug}', but " &
"there is no corresponding directory at the below location"
result.setFalseAndPrint(msg, slugDir)
block:
let contentPath = slugDir / "content.md"
let msg = &"A config.json '{context}.slug' value is '{slug}', but " &
"there is no corresponding content file at the below location"
result.setFalseIfFileMissingOrEmpty(contentPath, msg)
block:
let ext = if dkDir.endsWith($dkApproaches): "txt" else: "md"
let snippetPath = slugDir / &"snippet.{ext}"
let msg = &"A config.json '{context}.slug' value is '{slug}', but " &
"there is no corresponding snippet file at the below location"
result.setFalseIfFileMissingOrEmpty(snippetPath, msg)

proc getSlugs(data: JsonNode, k: string): seq[string] =
result = @[]
if data.kind == JObject and data.hasKey(k):
if data[k].kind == JArray:
let elems = data[k].getElems()
for e in elems:
if e.kind == JObject and e.hasKey("slug") and e["slug"].kind == JString:
result.add e["slug"].getStr()

proc isValidConfig(data: JsonNode, path: Path, dk: DirKind): bool =
if isObject(data, jsonRoot, path):
let k = dk.`$`[1..^1] # Remove dot.
let checks = [
if dk == dkApproaches: hasValidIntroduction(data, path) else: true,
hasArrayOf(data, k, path, isValidApproachOrArticle, isRequired = false),
]
result = allTrue(checks)
if result:
let slugsInConfig = getSlugs(data, k)
for dir in getSortedSubdirs(path.parentDir().Path, relative = true):
if dir.string notin slugsInConfig:
let msg = &"There is no '{k}.slug' key with the value '{dir}', " &
&"but a sibling directory exists with that name"
result.setFalseAndPrint(msg, path)

proc isConfigMissingOrValid(dir: Path, dk: DirKind): bool =
result = true
let dkPath = dir / $dk
let configPath = dkPath / "config.json"
if fileExists(configPath):
let j = parseJsonFile(configPath, result)
if j != nil:
if not isValidConfig(j, configPath, dk):
result = false
else:
if dk == dkApproaches and fileExists(dkPath / "introduction.md"):
let msg = &"The below directory has an 'introduction.md' file, but " &
"does not contain a 'config.json' file"
result.setFalseAndPrint(msg, dkPath)
for dir in getSortedSubdirs(dkPath, relative = true):
let msg = &"The below directory has a '{dir}' subdirectory, but does " &
"not contain a 'config.json' file"
result.setFalseAndPrint(msg, dkPath)

func countLinesWithoutCodeFence(s: string, dk: DirKind): int =
## Returns the number of lines in `s`, but:
##
## - excluding lines that open or close a Markdown code fence.
## - including a final line that does not end in a newline character.
result = 0
if s.len > 0:
for line in s.splitLines():
if not (line.startsWith("```") and dk == dkArticles):
inc result
if s[^1] in ['\n', '\l']:
dec result

proc isEverySnippetValid(exerciseDir: Path, dk: DirKind): bool =
result = true
for dir in getSortedSubdirs(exerciseDir / $dk):
let snippetPath = block:
let ext = if dk == dkApproaches: "txt" else: "md"
dir / &"snippet.{ext}"
if fileExists(snippetPath):
let contents = readFile(snippetPath)
const maxLines = 8
let numLines = countLinesWithoutCodeFence(contents, dk)
if numLines > maxLines:
let msg = &"The file is {numLines} lines long, but it must be at " &
&"most {maxLines} lines long"
result.setFalseAndPrint(msg, snippetPath)

proc isEveryApproachAndArticleValid*(trackDir: Path): bool =
result = true
for exerciseKind in ["concept", "practice"]:
for exerciseDir in getSortedSubdirs(trackDir / "exercises" / exerciseKind):
for dk in DirKind:
if not isConfigMissingOrValid(exerciseDir, dk):
result = false
if not isEverySnippetValid(exerciseDir, dk):
result = false
6 changes: 4 additions & 2 deletions src/lint/lint.nim
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import std/[strformat, strutils]
import ".."/[cli, helpers]
import "."/[concept_exercises, concepts, docs, practice_exercises,
track_config, validators]
import "."/[approaches_and_articles, concept_exercises, concepts, docs,
practice_exercises, track_config, validators]

proc allChecksPass(trackDir: Path): bool =
## Returns true if all the linting checks pass for the track at `trackDir`.
Expand All @@ -16,6 +16,7 @@ proc allChecksPass(trackDir: Path): bool =
isEveryConceptConfigValid(trackDir),
isEveryConceptExerciseConfigValid(trackDir),
isEveryPracticeExerciseConfigValid(trackDir),
isEveryApproachAndArticleValid(trackDir),
sharedExerciseDocsExist(trackDir),
trackDocsExist(trackDir),
]
Expand Down Expand Up @@ -45,6 +46,7 @@ proc lint*(conf: Conf) =
- Every concept exercise has a valid .meta/config.json file
- Every practice exercise has the required .md files
- Every practice exercise has a valid .meta/config.json file
- Every approach and article is valid
- Required track docs are present
- Required shared exercise docs are present""".dedent()
if printedWarning:
Expand Down
69 changes: 68 additions & 1 deletion tests/test_lint.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import std/unittest
import std/[strutils, unittest]
import "."/lint/validators
import "."/lint/approaches_and_articles {.all.}

proc testIsKebabCase =
suite "isKebabCase":
Expand Down Expand Up @@ -279,11 +280,77 @@ proc testIsFilesPattern =
isFilesPattern("somedir/%{pascal_slug}/%{snake_slug}.suffix")
isFilesPattern("%{pascal_slug}/filename.suffix")

proc testCountLinesWithoutCodeFence =
suite "countLinesWithoutCodeFence":
test "without code fence":
const dk = dkArticles
check:
countLinesWithoutCodeFence("", dk) == 0
countLinesWithoutCodeFence("a", dk) == 1
countLinesWithoutCodeFence("a\n", dk) == 1
countLinesWithoutCodeFence("foo\n", dk) == 1
countLinesWithoutCodeFence("foo\nb", dk) == 2
countLinesWithoutCodeFence("foo\nbar", dk) == 2
countLinesWithoutCodeFence("foo\nbar\n", dk) == 2
countLinesWithoutCodeFence("foo\nbar\nfoo", dk) == 3

test "with code fence only":
const s = """
```nim
echo "foo"
```""".unindent()
check:
countLinesWithoutCodeFence(s, dkArticles) == 1
countLinesWithoutCodeFence(s, dkApproaches) == 3

test "with code fence at start":
const s = """
```nim
echo "foo"
```
Some content.
""".unindent()
check:
countLinesWithoutCodeFence(s, dkArticles) == 3
countLinesWithoutCodeFence(s, dkApproaches) == 5

test "with code fence at end":
const s = """
# Some header
Some content.
```nim
echo "foo"
```
""".unindent()
check:
countLinesWithoutCodeFence(s, dkArticles) == 5
countLinesWithoutCodeFence(s, dkApproaches) == 7

test "with code fence in middle":
const s = """
# Some header
Some content.
```nim
echo "foo"
```
Some content.
""".unindent()
check:
countLinesWithoutCodeFence(s, dkArticles) == 7
countLinesWithoutCodeFence(s, dkApproaches) == 9

proc main =
testIsKebabCase()
testIsUuidV4()
testExtractPlaceholders()
testIsFilesPattern()
testCountLinesWithoutCodeFence()

main()
{.used.}

0 comments on commit 8907279

Please sign in to comment.