Skip to content

Commit

Permalink
lint: check files key in track-level config (#435)
Browse files Browse the repository at this point in the history
With this commit, `configlet lint` now checks that a track-level
`config.json` file follows the below rules:

- The `files` key is optional
- The `files` value must be an object
- The `files.solution` key is optional
- The `files.solution` value must be an array
- The `files.solution` values must be valid patterns
- The `files.solution` values must not have duplicates
- The `files.test` key is optional
- The `files.test` value must be an array
- The `files.test` values must be valid patterns
- The `files.test` values must not have duplicates
- The `files.example` key is optional
- The `files.example` value must be an array
- The `files.example` values must be valid patterns
- The `files.example` values must not have duplicates
- The `files.exemplar` key is optional
- The `files.exemplar` value must be an array
- The `files.exemplar` values must be valid patterns
- The `files.exemplar` values must not have duplicates
- The `files.editor` key is optional
- The `files.editor` value must be an array
- The `files.editor` values must be valid patterns
- The `files.editor` values must not have duplicates
- Patterns can only be listed in either the `files.solution`,
  `files.test`, `files.example`, `files.exemplar` or `files.editor`
  array (no overlap)

We define a "valid pattern" as a non-blank string that specifies
a location of a file used in an exercise, relative to the exercise's
directory. A pattern may use one of the following placeholders:

- `%{kebab_slug}`: the `kebab-case` exercise slug (e.g. `bit-manipulation`)
- `%{snake_slug}`: the `snake_case` exercise slug (e.g. `bit_manipulation`)
- `%{camel_slug}`: the `camelCase` exercise slug (e.g. `bitManipulation`)
- `%{pascal_slug}`: the `PascalCase` exercise slug (e.g. `BitManipulation`)

Co-authored-by: ee7 <45465154+ee7@users.noreply.github.com>
  • Loading branch information
bobtfish and ee7 authored Oct 13, 2021
1 parent ea478ef commit 2a6bfa9
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 5 deletions.
26 changes: 26 additions & 0 deletions src/lint/track_config.nim
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ proc hasValidOnlineEditor(data: JsonNode; path: Path): bool =
]
result = allTrue(checks)

proc hasValidFiles(data: JsonNode; path: Path): bool =
const f = "files"
if hasKey(data, f):
if hasObject(data, f, path, isRequired = false):
let checks = [
hasArrayOfStrings(data[f], "solution", path, context = f,
uniqueValues = true, isRequired = false,
checkIsFilesPattern = true),
hasArrayOfStrings(data[f], "test", path, context = f,
uniqueValues = true, isRequired = false,
checkIsFilesPattern = true),
hasArrayOfStrings(data[f], "example", path, context = f,
uniqueValues = true, isRequired = false,
checkIsFilesPattern = true),
hasArrayOfStrings(data[f], "exemplar", path, context = f,
uniqueValues = true, isRequired = false,
checkIsFilesPattern = true),
hasArrayOfStrings(data[f], "editor", path, context = f,
uniqueValues = true, isRequired = false,
checkIsFilesPattern = true),
]
result = allTrue(checks)
else:
result = true

proc hasValidTestRunner(data: JsonNode; path: Path): bool =
const s = "status"
if hasObject(data, s, path):
Expand Down Expand Up @@ -219,6 +244,7 @@ proc satisfiesFirstPass(data: JsonNode; path: Path): bool =
hasInteger(data, "version", path, allowed = 3..3),
hasValidStatus(data, path),
hasValidOnlineEditor(data, path),
hasValidFiles(data, path),
hasValidTestRunner(data, path),
hasValidExercises(data, path),
hasValidConcepts(data, path),
Expand Down
88 changes: 83 additions & 5 deletions src/lint/validators.nim
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,66 @@ func isUuidV4*(s: string): bool =
s[34] in Hex and
s[35] in Hex

iterator extractPlaceholders*(s: string): string =
var i = 0
var expectClosingBrace = false
var ph = ""
while i < s.len:
let c = s[i]
if not expectClosingBrace:
if c == '%':
if i+1 < s.len and s[i+1] == '{':
expectClosingBrace = true
inc i
else:
if c == '}':
yield ph
ph.setLen(0)
expectClosingBrace = false
else:
ph.add c
inc i

const filesPlaceholders = [
"kebab_slug",
"snake_slug",
"camel_slug",
"pascal_slug"
].toHashSet()

func isFilesPattern*(s: string): bool =
if not isEmptyOrWhitespace(s):
result = true
for ph in extractPlaceholders(s):
if ph notin filesPlaceholders:
return false

func list(a: SomeSet[string]; prefix = ""; suffix = ""): string =
## Returns a string that lists the elements of `a`, with `prefix` before
## each element, and `suffix` after each element.
var seen = 0
for item in a:
if seen == 0:
# Use the first item to estimate the final string length.
let estimatedLen = a.len * (item.len + prefix.len + suffix.len + 5)
result = newStringOfCap(estimatedLen)
result.add prefix
result.add item
result.add suffix
inc seen
if seen < a.len-1:
result.add ", "
elif seen == a.len-1:
result.add ", and "

var seenUuids = initHashSet[string](250)
var seenFilePatterns = initHashSet[string](250)

proc isString*(data: JsonNode; key: string; path: Path; context: string;
isRequired = true; allowed = emptySetOfStrings;
checkIsUrlLike = false; maxLen = int.high; checkIsKebab = false;
checkIsUuid = false; isInArray = false): bool =
checkIsUuid = false; isInArray = false;
checkIsFilesPattern = false): bool =
result = true
case data.kind
of JString:
Expand Down Expand Up @@ -224,6 +278,25 @@ proc isString*(data: JsonNode; key: string; path: Path; context: string;
&"A {format(context, key)} value is {q s}, which is not a " &
"lowercased version 4 UUID"
result.setFalseAndPrint(msg, path)
elif checkIsFilesPattern:
if seenFilePatterns.containsOrIncl(s):
let msg =
&"A {format(context, key)} value is {q s}, which is not a unique " &
"`files` entry"
result.setFalseAndPrint(msg, path)
if isFilesPattern(s):
if "%{" in s and "}" notin s:
let msg =
&"A {format(context, key)} value is {q s}, which contains " &
"a possible malformed placeholder. It contains " &
"`%{` but not the terminating `}` character"
warn(msg, path)
else:
const placeholders = list(filesPlaceholders, "%{", "}")
let msg =
&"A {format(context, key)} value is {q s}, which is not a " &
&"valid file pattern. Allowed placeholders are: {placeholders}"
result.setFalseAndPrint(msg, path)
if not hasValidRuneLength(s, key, path, context, maxLen):
result = false
else:
Expand Down Expand Up @@ -270,7 +343,8 @@ proc isArrayOfStrings*(data: JsonNode;
uniqueValues = false;
allowed: HashSet[string];
allowedArrayLen: Slice;
checkIsKebab: bool): bool =
checkIsKebab: bool;
checkIsFilesPattern: bool): bool =
## Returns true in any of these cases:
## - `data` is a `JArray` with length in `allowedArrayLen` that contains only
## non-empty, non-blank strings.
Expand All @@ -286,7 +360,9 @@ proc isArrayOfStrings*(data: JsonNode;

for item in data:
if not isString(item, context, path, "", isRequired, allowed,
checkIsKebab = checkIsKebab, isInArray = true):
checkIsKebab = checkIsKebab,
checkIsFilesPattern = checkIsFilesPattern,
isInArray = true):
result = false
elif uniqueValues:
let itemStr = item.getStr()
Expand Down Expand Up @@ -322,15 +398,17 @@ proc hasArrayOfStrings*(data: JsonNode;
uniqueValues = false;
allowed = emptySetOfStrings;
allowedArrayLen = 1..int.high;
checkIsKebab = false): bool =
checkIsKebab = false;
checkIsFilesPattern = false): bool =
## Returns true in any of these cases:
## - `isArrayOfStrings` returns true for `data[key]`.
## - `data` lacks the key `key` and `isRequired` is false.
if data.hasKey(key, path, context, isRequired):
let contextAndKey = joinWithDot(context, key)
result = isArrayOfStrings(data[key], contextAndKey, path, isRequired,
uniqueValues, allowed, allowedArrayLen,
checkIsKebab = checkIsKebab)
checkIsKebab = checkIsKebab,
checkIsFilesPattern = checkIsFilesPattern)
elif not isRequired:
result = true

Expand Down
80 changes: 80 additions & 0 deletions tests/test_lint.nim
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,89 @@ proc testIsUuidV4 =
else:
check not isUuidV4(uuid)

func extractPlaceholders(s: string): seq[string] =
result = newSeq[string]()
for ph in extractPlaceholders(s):
result.add ph

proc testExtractPlaceholders =
suite "extractPlaceholder":
test "no placeholder":
check:
# 0 placeholder characters
extractPlaceholders("").len == 0
extractPlaceholders("foo").len == 0

# 1 placeholder character
extractPlaceholders("foo%").len == 0
extractPlaceholders("%foo").len == 0
extractPlaceholders("{foo").len == 0
extractPlaceholders("foo}").len == 0

# 2 placeholder characters
extractPlaceholders("%{foo").len == 0
extractPlaceholders("%foo}").len == 0
extractPlaceholders("{foo}").len == 0
extractPlaceholders("%foo{bar").len == 0

# 3 placeholder characters, but badly formed
extractPlaceholders("%foo{bar}").len == 0
extractPlaceholders("%}foo{").len == 0
extractPlaceholders("{%foo}").len == 0

test "one placeholder":
check:
extractPlaceholders("%{}") == @[""]
extractPlaceholders("%{f}") == @["f"]
extractPlaceholders("%{foo}") == @["foo"]
extractPlaceholders("prefix%{foo}") == @["foo"]
extractPlaceholders("%{foo}suffix") == @["foo"]
extractPlaceholders("prefix%{foo}suffix") == @["foo"]
extractPlaceholders("prefix%%{foo}suffix") == @["foo"]
extractPlaceholders("pre%fix%{foo}suffix") == @["foo"]
extractPlaceholders("pre%}fix%{foo}suffix") == @["foo"]
extractPlaceholders("prefix%{foo}s}uffix") == @["foo"]

test "multiple placeholders":
check:
extractPlaceholders("prefix%{foo}bar%{foo}suffix") == @["foo", "foo"]
extractPlaceholders("prefix%{foo}bar%{baz}suffix") == @["foo", "baz"]
extractPlaceholders("prefix%{foo}%{bar}%{baz}suffix") == @["foo", "bar", "baz"]

proc testIsFilesPattern =
suite "isFilesPattern":
test "invalid files patterns":
check:
not isFilesPattern("")
not isFilesPattern(" ")
not isFilesPattern("%{unknown_slug}suffix")
not isFilesPattern("prefix%{unknown_slug}suffix")
not isFilesPattern("prefix%{unknown_slug}")
not isFilesPattern("%{unknown_slug}")
not isFilesPattern("%{ssnake_slug}")
not isFilesPattern("%{snake_slugg}")
not isFilesPattern("somedir/%{snake_slug}/%{unknown_slug}.suffix")

test "valid files patterns":
check:
isFilesPattern("somefile")
isFilesPattern("somefile.go")
isFilesPattern("%{kebab_slug}.js")
isFilesPattern("foo%{snake_slug}bar")
isFilesPattern("foobar%{camel_slug}")
isFilesPattern("%{pascal_slug}")
isFilesPattern("somedir/%{pascal_slug}")
isFilesPattern("somedir/%{pascal_slug}.suffix")
isFilesPattern("somedir/%{pascal_slug}/filename.suffix")
isFilesPattern("somedir/%{pascal_slug}/%{pascal_slug}.suffix")
isFilesPattern("somedir/%{pascal_slug}/%{snake_slug}.suffix")
isFilesPattern("%{pascal_slug}/filename.suffix")

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

main()
{.used.}

0 comments on commit 2a6bfa9

Please sign in to comment.