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
68 changes: 60 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,47 @@ proc isUrlLike(s: string): bool =
const
emptySetOfStrings = initHashSet[string](0)

func isKebabCase*(s: string): bool =
## Returns true if `s` is a kebab-case string. By our definition, `s` must:
## - Have a non-zero length
## - Start and end with a character in `[a-z0-9]`
## - Consist only of characters in `[a-z0-9-]`
## - Not contain consecutive `-` characters
##
## This is equivalent to matching the below regular expression:
##
## `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
##
## However, this func's implementation is faster than one that uses
## `re.match`, and doesn't add a PCRE dependency.
runnableExamples:
assert isKebabCase("hello")
assert isKebabCase("hello-world")
assert isKebabCase("123") # Can contain only digits.
assert not isKebabCase("") # Cannot be the empty string.
assert not isKebabCase("hello world") # Cannot contain a space.
assert not isKebabCase("hello_world") # Cannot contain an underscore.
assert not isKebabCase("helloWorld") # Cannot contain an uppercase letter.
assert not isKebabCase("hello--world") # Cannot contain consecutive dashes.
assert not isKebabCase("hello!") # Cannot contain a special character.

const lowerAndDigits = {'a'..'z', '0'..'9'}
let sLen = s.len
var i = 0
while i < sLen:
if s[i] == '-':
return false
i += s.skipWhile(lowerAndDigits, start = i)
if i == sLen:
return true
elif s[i] == '-':
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 +139,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 {q s}, but every " &
"value must be lowercase and kebab-case"
else:
&"The {format(context, key)} value is {q 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 +179,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 +192,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 +207,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 +234,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,
]
70 changes: 70 additions & 0 deletions tests/test_lint.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import std/unittest
import "."/[lint/validators]

proc main =
suite "isKebabCase":
test "invalid kebab-case strings":
check:
# Some short, invalid strings
not isKebabCase("")
not isKebabCase(" ")
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")
# Containing character not in [a-z0-9]
not isKebabCase("&")
not isKebabCase("&str")
not isKebabCase("hello!")
# Invalid dash usage
not isKebabCase("hello-world-")
not isKebabCase("-hello-world")
not isKebabCase("-hello-world-")
not isKebabCase("hello--world")
not isKebabCase("hello---world")
# Invalid separator: space
not isKebabCase("hello world")
not isKebabCase("hello World")
not isKebabCase("Hello world")
not isKebabCase("Hello World")
not isKebabCase("HELLO WORLD")
# Invalid separator: underscore
not isKebabCase("hello_world")
not isKebabCase("hello_World")
not isKebabCase("Hello_world")
not isKebabCase("Hello_World")
not isKebabCase("HELLO_WORLD")
# Containing uppercase, with dash
not isKebabCase("hello-World")
not isKebabCase("Hello-world")
not isKebabCase("Hello-World")
not isKebabCase("HELLO-WORLD")
# Containing uppercase, with no separator
not isKebabCase("helloWorld")
not isKebabCase("Helloworld")
not isKebabCase("HelloWorld")
not isKebabCase("HELLOWORLD")

test "valid kebab-case strings":
check:
isKebabCase("a")
isKebabCase("1")
isKebabCase("123")
isKebabCase("123-456")
isKebabCase("hello-123")
isKebabCase("123-hello")
isKebabCase("hello")
isKebabCase("hello-world")
isKebabCase("hello-world-hello")
isKebabCase("hello-world-hello-world")

main()
{.used.}