diff --git a/src/lint/track_config.nim b/src/lint/track_config.nim index 75e733a6..7092194e 100644 --- a/src/lint/track_config.nim +++ b/src/lint/track_config.nim @@ -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), ] @@ -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), ] @@ -114,7 +114,8 @@ 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) @@ -122,7 +123,7 @@ 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) @@ -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), diff --git a/src/lint/validators.nim b/src/lint/validators.nim index 18d75227..71d191db 100644 --- a/src/lint/validators.nim +++ b/src/lint/validators.nim @@ -75,9 +75,16 @@ proc isUrlLike(s: string): bool = const emptySetOfStrings = initHashSet[string](0) +func isLowerKebab(s: string): bool = + ## Returns true if `s` is a lowercase and kebab-case string. + result = true + for c in s: + if c notin {'a'..'z', '0'..'9', '-'}: + 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 @@ -101,6 +108,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 isLowerKebab(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: @@ -131,10 +148,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 @@ -143,7 +161,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. @@ -157,7 +176,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}, " & @@ -184,14 +203,15 @@ 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