Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lint: add kebab-case checks #274

Merged
merged 11 commits into from
Apr 10, 2021
19 changes: 10 additions & 9 deletions src/lint/track_config.nim
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ const
proc isValidConceptExercise(data: JsonNode; context: string; path: Path): bool =
if isObject(data, context, path):
let checks = [
hasString(data, "slug", path, context),
hasString(data, "slug", path, context, checkIsKebab = true),
hasString(data, "name", path, context),
hasString(data, "uuid", path, context),
hasBoolean(data, "deprecated", path, context, isRequired = false),
hasArrayOfStrings(data, "concepts", path, context,
allowedArrayLen = 0..int.high),
allowedArrayLen = 0..int.high, checkIsKebab = true),
hasArrayOfStrings(data, "prerequisites", path, context,
allowedArrayLen = 0..int.high),
allowedArrayLen = 0..int.high, checkIsKebab = true),
hasString(data, "status", path, context, isRequired = false,
allowed = statuses),
]
Expand All @@ -91,15 +91,15 @@ proc isValidPracticeExercise(data: JsonNode; context: string;
path: Path): bool =
if isObject(data, context, path):
let checks = [
hasString(data, "slug", path, context),
hasString(data, "slug", path, context, checkIsKebab = true),
hasString(data, "name", path, context),
hasString(data, "uuid", path, context),
hasBoolean(data, "deprecated", path, context, isRequired = false),
hasInteger(data, "difficulty", path, context, allowed = 0..10),
hasArrayOfStrings(data, "practices", path, context,
allowedArrayLen = 0..int.high),
allowedArrayLen = 0..int.high, checkIsKebab = true),
hasArrayOfStrings(data, "prerequisites", path, context,
allowedArrayLen = 0..int.high),
allowedArrayLen = 0..int.high, checkIsKebab = true),
hasString(data, "status", path, context, isRequired = false,
allowed = statuses),
]
Expand All @@ -114,15 +114,16 @@ proc hasValidExercises(data: JsonNode; path: Path): bool =
allowedLength = 0..int.high),
hasArrayOf(exercises, "practice", path, isValidPracticeExercise, k,
allowedLength = 0..int.high),
hasArrayOfStrings(exercises, "foregone", path, k, isRequired = false),
hasArrayOfStrings(exercises, "foregone", path, k, isRequired = false,
checkIsKebab = true),
]
result = allTrue(checks)

proc isValidConcept(data: JsonNode; context: string; path: Path): bool =
if isObject(data, context, path):
let checks = [
hasString(data, "uuid", path, context),
hasString(data, "slug", path, context),
hasString(data, "slug", path, context, checkIsKebab = true),
hasString(data, "name", path, context),
]
result = allTrue(checks)
Expand Down Expand Up @@ -153,7 +154,7 @@ proc isValidTrackConfig(data: JsonNode; path: Path): bool =
if isObject(data, "", path):
let checks = [
hasString(data, "language", path),
hasString(data, "slug", path),
hasString(data, "slug", path, checkIsKebab = true),
hasBoolean(data, "active", path),
hasString(data, "blurb", path, maxLen = 400),
hasInteger(data, "version", path, allowed = 3..3),
Expand Down
54 changes: 46 additions & 8 deletions src/lint/validators.nim
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import std/[json, os, sets, streams, strformat, strutils, unicode]
import std/[json, os, parseutils, sets, streams, strformat, strutils, unicode]
import ".."/helpers

func allTrue*(bools: openArray[bool]): bool =
Expand Down Expand Up @@ -75,9 +75,33 @@ proc isUrlLike(s: string): bool =
const
emptySetOfStrings = initHashSet[string](0)

func isKebabCase*(s: string): bool =
## Returns true if `s` satisfies these rules:
## - Has a length > 0
## - Begins and ends with a character in [a-z0-9]
## - Consists only of characters in [a-z0-9-]
## - Does not contain two adjacent `-` characters
## This corresponds to the regex pattern `^[a-z0-9]+(-[a-z0-9]+)*$`.
const lowerAndDigits = {'a'..'z', '0'..'9'}
let L = s.len

if L > 0 and s[0] in lowerAndDigits and s[^1] in lowerAndDigits:
var i = 0
while true:
i += s.skipWhile(lowerAndDigits, start = i)
if i == L:
return true
elif s[i] == '-':
if s[i-1] == '-':
return false
else:
inc i
else:
return false

proc isString*(data: JsonNode; key: string; path: Path; context: string;
isRequired = true; allowed = emptySetOfStrings;
checkIsUrlLike = false; maxLen = int.high;
checkIsUrlLike = false; maxLen = int.high; checkIsKebab = false;
isInArray = false): bool =
result = true
case data.kind
Expand All @@ -101,6 +125,16 @@ proc isString*(data: JsonNode; key: string; path: Path; context: string;
result.setFalseAndPrint(&"Not a valid URL: {q s}", path)
elif s.len > 0:
if not isEmptyOrWhitespace(s):
if checkIsKebab:
if not isKebabCase(s):
let msg =
if isInArray:
&"The {format(context, key)} array contains {s}, but every " &
"value must be lowercase and kebab-case"
else:
&"The {format(context, key)} value is {s}, but it must be a " &
"lowercase and kebab-case string"
result.setFalseAndPrint(msg, path)
if not hasValidRuneLength(s, key, path, context, maxLen):
result = false
else:
Expand Down Expand Up @@ -131,10 +165,11 @@ proc isString*(data: JsonNode; key: string; path: Path; context: string;

proc hasString*(data: JsonNode; key: string; path: Path; context = "";
isRequired = true; allowed = emptySetOfStrings;
checkIsUrlLike = false; maxLen = int.high): bool =
checkIsUrlLike = false; maxLen = int.high;
checkIsKebab = false): bool =
if data.hasKey(key, path, context, isRequired):
result = isString(data[key], key, path, context, isRequired, allowed,
checkIsUrlLike, maxLen)
checkIsUrlLike, maxLen, checkIsKebab = checkIsKebab)
elif not isRequired:
result = true

Expand All @@ -143,7 +178,8 @@ proc isArrayOfStrings*(data: JsonNode;
path: Path;
isRequired = true;
allowed: HashSet[string];
allowedArrayLen: Slice): bool =
allowedArrayLen: Slice;
checkIsKebab: 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 @@ -157,7 +193,7 @@ proc isArrayOfStrings*(data: JsonNode;
if arrayLen in allowedArrayLen:
for item in data:
if not isString(item, context, path, "", isRequired, allowed,
isInArray = true):
checkIsKebab = checkIsKebab, isInArray = true):
result = false
else:
let msgStart = &"The {q context} array has length {arrayLen}, " &
Expand All @@ -184,14 +220,16 @@ proc hasArrayOfStrings*(data: JsonNode;
context = "";
isRequired = true;
allowed = emptySetOfStrings;
allowedArrayLen = 1..int.high): bool =
allowedArrayLen = 1..int.high;
checkIsKebab = 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,
allowed, allowedArrayLen)
allowed, allowedArrayLen,
checkIsKebab = checkIsKebab)
elif not isRequired:
result = true

Expand Down
1 change: 1 addition & 0 deletions tests/all_tests.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "."/[
test_binary,
test_lint,
test_probspecs,
test_uuid,
]
63 changes: 63 additions & 0 deletions tests/test_lint.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import std/unittest
import "."/[lint/validators]

proc main =
suite "isKebabCase":
test "invalid kebab strings":
check:
# Some short, bad strings
not isKebabCase("")
not isKebabCase(" ")
not isKebabCase("-")
not isKebabCase("_")
not isKebabCase("a ")
not isKebabCase(" a")
not isKebabCase("a-")
not isKebabCase("-a")
not isKebabCase("--a")
not isKebabCase("a--")
not isKebabCase("-a-")
not isKebabCase("a--b")
# With symbols
not isKebabCase("&")
not isKebabCase("&str")
not isKebabCase("hello!")
# Bad dash usage
not isKebabCase("hello-world-")
not isKebabCase("-hello-world")
not isKebabCase("-hello-world-")
not isKebabCase("hello--world")
not isKebabCase("hello---world")
# With space
not isKebabCase("hello world")
not isKebabCase("hello World")
not isKebabCase("Hello world")
not isKebabCase("Hello World")
not isKebabCase("HELLO WORLD")
# With underscore
not isKebabCase("hello_world")
not isKebabCase("hello_World")
not isKebabCase("Hello_world")
not isKebabCase("Hello_World")
not isKebabCase("HELLO_WORLD")
# With dash
not isKebabCase("hello-World")
not isKebabCase("Hello-world")
not isKebabCase("Hello-World")
not isKebabCase("HELLO-WORLD")
# No spaces, but with capitals
not isKebabCase("helloWorld")
not isKebabCase("Helloworld")
not isKebabCase("HelloWorld")
not isKebabCase("HELLOWORLD")

test "valid kebab strings":
check:
isKebabCase("a")
isKebabCase("hello")
isKebabCase("hello-world")
isKebabCase("hello-world-hello")
isKebabCase("hello-world-hello-world")

main()
{.used.}