Skip to main content

tramlines_core/grammar/
lower.rs

1//! The lowering of the keys every format shares: a typed [`CoreSchema`]
2//! from a schema YAML that passed the generic check. A format crate wraps
3//! [`Lowerer`] and lowers its own keys beside these.
4
5use std::collections::{BTreeMap, HashSet};
6
7use regex::Regex;
8
9use super::{
10    CoreSchema, DerivationSource, Example, FormatRegistry, FormatSource, Grammar, KeyDerivation,
11    LifecycleDimension, Only, RegexCache, RuleCommon, TypeRegistry,
12};
13use crate::{Finding, Problem, RepoPath, Severity, Spanned, Yaml};
14
15// sokf-implements: CORE-6_AC-1
16// sokf:begin lowering
17/// The keys of `schema` the core grammar lowers.
18pub const CORE_SCHEMA_KEYS: &[&str] = &[
19    "line-limit",
20    "guidance",
21    "lifecycle",
22    "frontmatter",
23    "frontmatter-open",
24    "prohibited-patterns",
25    "formats",
26    "types",
27    "examples",
28];
29// sokf:end lowering
30
31/// The keys of `lifecycle-dimension` the engine lowers.
32const DIMENSION_KEYS: &[&str] = &["key", "states", "guidance"];
33
34/// The entries of `node` whose keys `known` does not name, as written.
35// sokf-implements: CORE-3_AC-3
36pub fn extensions(node: &Spanned, known: &[&str]) -> BTreeMap<String, Spanned> {
37    node.as_map()
38        .unwrap_or(&[])
39        .iter()
40        .filter(|(k, _)| !known.contains(&k.value.as_str()))
41        .map(|(k, v)| (k.value.clone(), v.clone()))
42        .collect()
43}
44
45/// The registry a schema sees: its own `formats` and `types` as the
46/// nearest layer on `registry`. A refusal is added to `findings`, and the
47/// schema then sees `registry` alone.
48// sokf-implements: TL_34
49// sokf-implements: TL_36
50// sokf-implements: CORE-6_AC-5
51// sokf:begin lowering
52pub fn lower_formats(
53    yaml: &Spanned,
54    grammar: &Grammar,
55    registry: &FormatRegistry,
56    regexes: &RegexCache,
57    schema: &RepoPath,
58    schema_id: &str,
59    findings: &mut Vec<Finding>,
60) -> FormatRegistry {
61    // sokf:end lowering
62    if yaml.get("formats").is_none() && yaml.get("types").is_none() {
63        return registry.clone();
64    }
65    let none = Spanned::new(Yaml::Map(vec![]), yaml.line);
66    // What loads is kept, so a type or a format that fails is reported
67    // once, and not again by each rule that uses it.
68    let (own, refused) = FormatRegistry::layer(
69        yaml.get("formats").unwrap_or(&none),
70        yaml.get("types"),
71        FormatSource::Schema(schema_id.to_string()),
72        grammar,
73        regexes,
74        schema,
75        Some(registry),
76    );
77    // sokf-implements: CORE-6_AC-6
78    findings.extend(refused);
79    registry.layered(&own)
80}
81
82/// The keys every rule map accepts, `severity` and `only`, lowered for a
83/// rule map a host's extension adds: `only` resolves against the schema's
84/// lifecycle.
85// sokf-implements: CORE-6_AC-7
86// sokf:begin lowering
87pub fn lower_common(
88    node: &Spanned,
89    schema: &CoreSchema,
90    path: &RepoPath,
91) -> (RuleCommon, Vec<Finding>) {
92    // sokf:end lowering
93    let regexes = RegexCache::new();
94    let mut l = Lowerer::new(&regexes, path, TypeRegistry::default());
95    l.lifecycle = schema.lifecycle.clone();
96    let common = l.common(node);
97    (common, l.findings)
98}
99
100// sokf:begin lowerer
101/// The state of one lowering: the schema it reports on, what it found,
102/// and what it has read so far that later keys resolve against.
103pub struct Lowerer<'a> {
104    /// The cache the schema's regexes compile through.
105    pub regexes: &'a RegexCache,
106    /// The schema that every finding is reported on.
107    pub schema: &'a RepoPath,
108    /// The findings so far.
109    pub findings: Vec<Finding>,
110    /// The `rule-id`s declared so far.
111    pub rule_ids: HashSet<String>,
112    /// The lifecycle, which `only` resolves against.
113    pub lifecycle: Vec<LifecycleDimension>,
114    /// The types a template of this schema is written with.
115    pub types: TypeRegistry,
116}
117// sokf:end lowerer
118
119/// The string at `key` of `node`.
120pub fn str_of(node: &Spanned, key: &str) -> Option<String> {
121    node.get(key).and_then(|v| v.as_str()).map(str::to_string)
122}
123
124/// A grammar `key-derivation`: a rule's keys or a frontmatter value.
125pub fn derivation(node: &Spanned) -> KeyDerivation {
126    let source = match str_of(node, "from") {
127        Some(key) => DerivationSource::From(key),
128        None => DerivationSource::Rule(str_of(node, "rule").unwrap_or_default()),
129    };
130    KeyDerivation {
131        source,
132        separator: str_of(node, "separator").unwrap_or_default(),
133    }
134}
135
136/// The boolean at `key` of `node`; absent means false.
137pub fn bool_of(node: &Spanned, key: &str) -> bool {
138    node.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
139}
140
141/// The count at `key` of `node`; a negative count reads as zero.
142pub fn usize_of(node: &Spanned, key: &str) -> Option<usize> {
143    node.get(key)
144        .and_then(|v| v.as_int())
145        .map(|i| usize::try_from(i).unwrap_or(0))
146}
147
148/// The string or the list of strings at `key` of `node`.
149pub fn list_of(node: &Spanned, key: &str) -> Vec<String> {
150    node.get(key)
151        .and_then(|v| v.as_str_or_list())
152        .unwrap_or_default()
153}
154
155impl<'a> Lowerer<'a> {
156    /// A lowering of a schema at `schema`, whose templates see `types`.
157    // sokf:begin lowering
158    pub fn new(regexes: &'a RegexCache, schema: &'a RepoPath, types: TypeRegistry) -> Self {
159        // sokf:end lowering
160        Lowerer {
161            regexes,
162            schema,
163            findings: Vec::new(),
164            rule_ids: HashSet::new(),
165            lifecycle: Vec::new(),
166            types,
167        }
168    }
169
170    /// A finding on the schema, at `line`, that breaks `criterion`.
171    pub fn fail(&mut self, line: usize, criterion: &'static str, message: impl Into<String>) {
172        self.findings.push(Finding::error(
173            self.schema.clone(),
174            Some(line),
175            criterion,
176            message,
177        ));
178    }
179
180    /// A finding on the schema, at `line`, for `problem`.
181    pub fn fail_with(&mut self, line: usize, problem: Problem) {
182        self.findings
183            .push(problem.error(self.schema.clone(), Some(line)));
184    }
185
186    /// The regex at `key` of `node`, checked where the grammar was read.
187    pub fn regex(&self, node: &Spanned, key: &str) -> Option<Regex> {
188        let text = node.get(key)?.as_str()?;
189        self.regexes.get(text).ok()
190    }
191
192    /// The `prohibited-patterns` of `node`.
193    pub fn prohibitions(&self, node: &Spanned) -> Vec<super::Prohibition> {
194        super::prohibitions(node, self.regexes)
195    }
196
197    /// The `only` of a rule, resolved against the lifecycle.
198    // sokf-implements: TL_19
199    // sokf-implements: TL_20
200    pub fn only(&mut self, node: &Spanned) -> Only {
201        let mut only = Only::default();
202        let Some(map) = node.get("only") else {
203            return only;
204        };
205        if self.lifecycle.is_empty() {
206            // sokf-implements: TL_111
207            self.fail(map.line, "TL_111", "`only` without a lifecycle");
208            return only;
209        }
210        for (dim, states) in map.as_map().unwrap_or(&[]) {
211            let Some(dimension) = self
212                .lifecycle
213                .iter()
214                .find(|d| d.key == dim.value)
215                .map(|d| d.states.clone())
216            else {
217                self.fail(
218                    dim.line,
219                    "TL_20",
220                    format!(
221                        "`only` names a dimension the lifecycle does not declare: `{}`",
222                        dim.value
223                    ),
224                );
225                continue;
226            };
227            let admitted = states.as_str_or_list().unwrap_or_default();
228            for s in &admitted {
229                if !dimension.contains(s) {
230                    self.fail(
231                        states.line,
232                        "TL_20",
233                        format!("`only` names `{s}`, which is no state of `{}`", dim.value),
234                    );
235                }
236            }
237            only.0.insert(dim.value.clone(), admitted);
238        }
239        only
240    }
241
242    /// The keys every rule map carries: `severity`, and `only`.
243    // sokf-implements: TL_18
244    pub fn common(&mut self, node: &Spanned) -> RuleCommon {
245        RuleCommon {
246            severity: match str_of(node, "severity").as_deref() {
247                Some("warn") => Severity::Warning,
248                _ => Severity::Error,
249            },
250            only: self.only(node),
251            line: node.line,
252        }
253    }
254
255    /// The keys of the core grammar of a schema: the lifecycle first, which
256    /// `only` resolves against, then the frontmatter, the bans and the
257    /// examples. `formats` is the registry the schema sees.
258    // sokf-implements: CORE-6_AC-2
259    // sokf:begin lowering
260    pub fn lower_core(&mut self, yaml: &Spanned, formats: FormatRegistry) -> CoreSchema {
261        // sokf:end lowering
262        self.lifecycle = yaml
263            .get("lifecycle")
264            .and_then(|l| l.as_seq())
265            .map(|dims| {
266                dims.iter()
267                    .map(|d| LifecycleDimension {
268                        key: str_of(d, "key").unwrap_or_default(),
269                        states: list_of(d, "states"),
270                        guidance: str_of(d, "guidance"),
271                        line: d.line,
272                        // sokf-implements: CORE-6_AC-8
273                        extensions: extensions(d, DIMENSION_KEYS),
274                    })
275                    .collect()
276            })
277            .unwrap_or_default();
278        let mut seen_keys = HashSet::new();
279        let twice: Vec<(usize, String)> = self
280            .lifecycle
281            .iter()
282            .filter(|d| !seen_keys.insert(d.key.as_str()))
283            .map(|d| (d.line, d.key.clone()))
284            .collect();
285        for (line, key) in twice {
286            // sokf-implements: CORE-6_AC-3
287            // sokf-implements: TL_95
288            self.fail(
289                line,
290                "TL_95",
291                format!("dimension `{key}` is declared twice"),
292            );
293        }
294
295        let mut frontmatter = Vec::new();
296        for (key, node) in yaml
297            .get("frontmatter")
298            .and_then(|f| f.as_map())
299            .unwrap_or(&[])
300        {
301            if self.lifecycle.iter().any(|d| d.key == key.value) {
302                // sokf-implements: CORE-6_AC-4
303                // sokf-implements: TL_112
304                self.fail(
305                    key.line,
306                    "TL_112",
307                    format!(
308                        "`{}` is a lifecycle dimension; it is not repeated under `frontmatter`",
309                        key.value
310                    ),
311                );
312            }
313            let c = self.frontmatter_constraint(node, &key.value);
314            frontmatter.push((key.value.clone(), c));
315        }
316        // A ban is a bare pattern; its failure quotes what it matched.
317        // sokf-implements: TL_17
318        let prohibited_patterns = self.prohibitions(yaml);
319        let examples = yaml
320            .get("examples")
321            .and_then(|e| e.as_seq())
322            .map(|items| {
323                items
324                    .iter()
325                    .map(|e| Example {
326                        document: str_of(e, "document").unwrap_or_default(),
327                        line: e.line,
328                    })
329                    .collect()
330            })
331            .unwrap_or_default();
332        CoreSchema {
333            line_limit: yaml.get("line-limit").and_then(|l| l.as_int()).unwrap_or(0),
334            lifecycle: self.lifecycle.clone(),
335            frontmatter,
336            frontmatter_open: bool_of(yaml, "frontmatter-open"),
337            prohibited_patterns,
338            formats,
339            guidance: str_of(yaml, "guidance"),
340            examples,
341            line: yaml.line,
342            key_lines: yaml
343                .as_map()
344                .unwrap_or(&[])
345                .iter()
346                .map(|(k, _)| (k.value.clone(), k.line))
347                .collect(),
348        }
349    }
350}
351
352mod checks;
353mod shapes;