///|
/// Structural validation runs before policy analysis and gives precise repair
/// locations for malformed keymaps.
pub(all) enum StructuralKind {
MissingRootContext
DuplicateId
EmptyId
EmptyCommand
InvalidIdentifier
MissingParent
ContextCycle
NegativeRank
InvalidPlatform
InvalidReservedMarker
InvalidShortcut
EmptyDescription
} derive(Eq, @debug.Debug)
///|
pub(all) struct StructuralIssue {
kind : StructuralKind
code : String
message : String
id : String
line : Int
severity : Severity
} derive(Eq, @debug.Debug)
///|
pub(all) struct ValidationReport {
keymap_name : String
issues : Array[StructuralIssue]
error_count : Int
warning_count : Int
valid : Bool
} derive(Eq, @debug.Debug)
///|
fn structural_name(kind : StructuralKind) -> String {
match kind {
MissingRootContext => "missing-root-context"
DuplicateId => "duplicate-id"
EmptyId => "empty-id"
EmptyCommand => "empty-command"
InvalidIdentifier => "invalid-identifier"
MissingParent => "missing-parent"
ContextCycle => "context-cycle"
NegativeRank => "negative-rank"
InvalidPlatform => "invalid-platform"
InvalidReservedMarker => "invalid-reserved-marker"
InvalidShortcut => "invalid-shortcut"
EmptyDescription => "empty-description"
}
}
///|
fn structural_issue(
kind : StructuralKind,
message : String,
id : String,
line : Int,
severity : Severity,
) -> StructuralIssue {
{ kind, code: "SV-" + structural_name(kind), message, id, line, severity }
}
///|
fn valid_identifier(value : String) -> Bool {
if value.length() == 0 {
return false
}
for character in value {
let n = character.to_int()
let valid = (n >= 65 && n <= 90) ||
(n >= 97 && n <= 122) ||
(n >= 48 && n <= 57) ||
character == '_' ||
character == '-' ||
character == '.'
if !valid {
return false
}
}
true
}
///|
fn valid_platform(value : String) -> Bool {
let clean = lower_ascii(trim_ascii(value))
if clean.length() == 0 {
return false
}
for part in platform_parts(clean) {
if !valid_identifier(part) {
return false
}
}
true
}
///|
fn context_has_cycle(keymap : Keymap, start : String) -> Bool {
let visited : Array[String] = []
let mut current = start
let mut steps = 0
while current.length() > 0 && steps <= keymap.contexts.length() {
if array_contains(visited, current) {
return true
}
visited.push(current)
match context_by_name(keymap, current) {
Some(context) => current = context.parent
None => current = ""
}
steps += 1
}
steps > keymap.contexts.length()
}
///|
/// Inspect all declarations without applying conflict policy.
pub fn validate_keymap(keymap : Keymap) -> ValidationReport {
let issues : Array[StructuralIssue] = []
if !context_exists(keymap, "global") {
issues.push(
structural_issue(
MissingRootContext,
"global context is required",
"global",
0,
Error,
),
)
}
for i, left in keymap.bindings {
if left.id.length() == 0 {
issues.push(
structural_issue(
EmptyId,
"binding id cannot be empty",
left.id,
left.line,
Error,
),
)
} else if !valid_identifier(left.id) {
issues.push(
structural_issue(
InvalidIdentifier,
"binding id contains unsupported characters",
left.id,
left.line,
Error,
),
)
}
if left.command.length() == 0 {
issues.push(
structural_issue(
EmptyCommand,
"command cannot be empty",
left.id,
left.line,
Error,
),
)
} else if !valid_identifier(left.command) {
issues.push(
structural_issue(
InvalidIdentifier,
"command contains unsupported characters",
left.id,
left.line,
Warning,
),
)
}
if !context_exists(keymap, left.context) {
issues.push(
structural_issue(
MissingParent,
"binding context is not declared",
left.id,
left.line,
Error,
),
)
}
if left.priority < 0 {
issues.push(
structural_issue(
NegativeRank,
"binding priority should not be negative",
left.id,
left.line,
Warning,
),
)
}
if !valid_platform(left.platform) {
issues.push(
structural_issue(
InvalidPlatform,
"platform is empty or malformed",
left.id,
left.line,
Error,
),
)
}
if left.keys.canonical.length() == 0 {
issues.push(
structural_issue(
InvalidShortcut,
"shortcut did not produce a canonical form",
left.id,
left.line,
Error,
),
)
}
if left.description.length() == 0 {
issues.push(
structural_issue(
EmptyDescription,
"description is recommended for reviewability",
left.id,
left.line,
Warning,
),
)
}
let mut j = i + 1
while j < keymap.bindings.length() {
let right = keymap.bindings[j]
if left.id == right.id {
issues.push(
structural_issue(
DuplicateId,
"binding id occurs more than once",
left.id,
right.line,
Error,
),
)
}
j += 1
}
}
for context in keymap.contexts {
if context.parent.length() > 0 && !context_exists(keymap, context.parent) {
issues.push(
structural_issue(
MissingParent,
"context parent is not declared",
context.name,
0,
Error,
),
)
}
if context.rank < 0 {
issues.push(
structural_issue(
NegativeRank,
"context rank should not be negative",
context.name,
0,
Warning,
),
)
}
if context_has_cycle(keymap, context.name) {
issues.push(
structural_issue(
ContextCycle,
"context parent chain contains a cycle",
context.name,
0,
Error,
),
)
}
}
for marker in keymap.reserved {
let (platform, shortcut) = split_once(marker, ":")
if !valid_platform(platform) || parse_keys(shortcut) is Err(_) {
issues.push(
structural_issue(
InvalidReservedMarker,
"reserved marker must be platform:shortcut",
marker,
0,
Error,
),
)
}
}
let errors = issues.filter(item => item.severity == Error).length()
let warnings = issues.filter(item => item.severity == Warning).length()
{
keymap_name: keymap.name,
issues,
error_count: errors,
warning_count: warnings,
valid: errors == 0,
}
}
///|
pub fn structural_issue_to_json(issue : StructuralIssue) -> String {
"{\"code\":" +
json_string(issue.code) +
",\"kind\":" +
json_string(structural_name(issue.kind)) +
",\"severity\":" +
json_string(issue.severity.name()) +
",\"message\":" +
json_string(issue.message) +
",\"id\":" +
json_string(issue.id) +
",\"line\":" +
issue.line.to_string() +
"}"
}
///|
pub fn validation_to_json(report : ValidationReport) -> String {
let rows : Array[String] = []
for issue in report.issues {
rows.push(structural_issue_to_json(issue))
}
"{\"keymap\":" +
json_string(report.keymap_name) +
",\"valid\":" +
(if report.valid { "true" } else { "false" }) +
",\"errors\":" +
report.error_count.to_string() +
",\"warnings\":" +
report.warning_count.to_string() +
",\"issues\":[" +
rows.join(",") +
"]}"
}
///|
pub fn validation_to_markdown(report : ValidationReport) -> String {
let lines : Array[String] = [
"## Structural validation",
"",
"Result: **" + (if report.valid { "VALID" } else { "INVALID" }) + "**",
"",
"| Severity | Code | Identifier | Message |",
"| --- | --- | --- | --- |",
]
for issue in report.issues {
lines.push(
"| " +
issue.severity.name() +
" | `" +
issue.code +
"` | `" +
issue.id +
"` | " +
issue.message +
" |",
)
}
if report.issues.length() == 0 {
lines.push("| - | - | - | no structural issues |")
}
lines.join("\n")
}