///|
/// Extensible project rules for teams that need conventions beyond built-ins.
pub(all) struct ProjectRules {
command_prefix : String
required_contexts : Array[String]
allowed_platforms : Array[String]
max_bindings_per_context : Int
require_modifier : Bool
require_description : Bool
fail_on_info : Bool
} derive(Eq, @debug.Debug)
///|
pub(all) struct RuleReport {
rules : ProjectRules
findings : Array[Finding]
passed : Bool
} derive(Eq, @debug.Debug)
///|
pub fn ProjectRules::default() -> ProjectRules {
{
command_prefix: "",
required_contexts: ["global"],
allowed_platforms: ["all", "windows", "mac", "linux"],
max_bindings_per_context: 0,
require_modifier: false,
require_description: false,
fail_on_info: false,
}
}
///|
fn rule_finding(
code : String,
kind : IssueKind,
severity : Severity,
message : String,
binding : Binding,
suggestion : String,
) -> Finding {
{
code,
kind,
severity,
message,
primary_id: binding.id,
secondary_id: "",
shortcut: binding.keys.canonical,
context: binding.context,
source: binding.source,
line: binding.line,
suggestion,
}
}
///|
fn has_required_context(keymap : Keymap, required : String) -> Bool {
context_exists(keymap, required)
}
///|
fn binding_count_for_context(keymap : Keymap, context : String) -> Int {
let mut count = 0
for binding in keymap.bindings {
if binding.context == context {
count += 1
}
}
count
}
///|
/// Apply project-specific naming, scope, density, and documentation rules.
pub fn evaluate_project_rules(
keymap : Keymap,
rules : ProjectRules,
) -> RuleReport {
let findings : Array[Finding] = []
for required in rules.required_contexts {
if !has_required_context(keymap, required) {
let fallback = Binding::new(
"__keymap__",
"__keymap__",
parse_keys("Ctrl+Shift+P").unwrap(),
)
findings.push(
rule_finding(
"PR001",
InvalidContext,
Error,
"required context '" + required + "' is missing",
fallback,
"declare the required context",
),
)
}
}
for binding in keymap.bindings {
if rules.command_prefix.length() > 0 &&
!binding.command.has_prefix(rules.command_prefix) {
findings.push(
rule_finding(
"PR002",
InvalidRecord,
Warning,
"command does not use the required project prefix",
binding,
"rename command to start with '" + rules.command_prefix + "'",
),
)
}
if rules.allowed_platforms.length() > 0 &&
!platform_contains_any(rules.allowed_platforms, binding.platform) {
findings.push(
rule_finding(
"PR003",
InvalidRecord,
Error,
"platform is not in the project allow-list",
binding,
"declare a supported platform",
),
)
}
if rules.require_modifier && !binding.keys.has_modifier {
findings.push(
rule_finding(
"PR004",
AccessibilityRisk,
Warning,
"project rules require a modifier on every binding",
binding,
"add Ctrl, Cmd, Alt, or Shift",
),
)
}
if rules.require_description && binding.description.length() == 0 {
findings.push(
rule_finding(
"PR005",
InvalidRecord,
Warning,
"project rules require a description",
binding,
"add description=...",
),
)
}
}
if rules.max_bindings_per_context > 0 {
for context in keymap.contexts {
let count = binding_count_for_context(keymap, context.name)
if count > rules.max_bindings_per_context {
let fallback = keymap.bindings.filter(binding => {
binding.context == context.name
})
if fallback.length() > 0 {
findings.push(
rule_finding(
"PR006",
InvalidRecord,
Warning,
"context contains " +
count.to_string() +
" bindings, over the configured density limit",
fallback[0],
"split the context or document the interaction model",
),
)
}
}
}
}
let passed = findings.filter(item => item.severity == Error).length() == 0 &&
(
!rules.fail_on_info ||
findings.filter(item => item.severity == Info).length() == 0
)
{ rules, findings, passed }
}
///|
pub fn rule_report_to_json(report : RuleReport) -> String {
let rows : Array[String] = []
for finding in report.findings {
rows.push(finding_json(finding))
}
"{\"passed\":" +
(if report.passed { "true" } else { "false" }) +
",\"findings\":[" +
rows.join(",") +
"]}"
}
///|
pub fn rule_report_to_markdown(report : RuleReport) -> String {
let lines : Array[String] = [
"## Project rule report",
"",
"Passed: **" + (if report.passed { "yes" } else { "no" }) + "**",
"",
]
if report.findings.length() == 0 {
lines.push("No project-specific rule findings.")
} else {
for finding in report.findings {
lines.push("- " + finding.to_line())
}
}
lines.join("\n")
}
///|
/// Combine built-in analysis with project rules while preserving evidence.
pub fn analyze_with_rules(keymap : Keymap, rules : ProjectRules) -> Analysis {
let base = analyze(keymap)
let extra = evaluate_project_rules(keymap, rules)
append_findings(base, extra.findings)
}