Skip to main content

meta_language/
concept_distinctions.rs

1//! Which concepts are shared across languages, and which must stay distinct.
2//!
3//! Two source spellings name one concept only under a one-to-one
4//! correspondence of meaning: each spelling resolves to exactly one concept
5//! record or foundation model, and it is the same one. The same spelling with
6//! different meanings, such as `|` in pest (ordered choice) and in BNF
7//! (unordered choice), or `panic!` in Rust (abort) and in Lean (a default
8//! value), never merges. Ordered and unordered choice, lexical and syntactic
9//! precedence, binding and assignment, and the integer, overflow, effect,
10//! universe, proof and logic models the foundation register keeps apart are
11//! required distinctions the check below enforces. The JavaScript twin is
12//! `js/src/concept-distinctions.js`.
13
14use std::collections::BTreeSet;
15
16use crate::concept_records::{ConceptRecord, SourceAlias, concept_records};
17use crate::foundation_models::{FoundationRegister, foundation_register};
18use crate::grammar::{Grammar, GrammarExpr, RuleKind};
19
20/// Two concepts that must never be merged, with the reason.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct RequiredDistinction {
23    /// The identities of the two concepts.
24    pub concepts: [&'static str; 2],
25    /// What distinguishes them.
26    pub reason: &'static str,
27}
28
29/// The concept pairs that stay distinct in every grammar and program.
30pub const REQUIRED_CONCEPT_DISTINCTIONS: &[RequiredDistinction] = &[
31    RequiredDistinction {
32        concepts: ["grammar.ordered-choice", "grammar.unordered-choice"],
33        reason: "An ordered choice commits to the first alternative that matches, while an unordered choice accepts every alternative that matches, so the two accept different languages.",
34    },
35    RequiredDistinction {
36        concepts: ["grammar.lexical-precedence", "grammar.syntactic-precedence"],
37        reason: "Lexical precedence decides which token the lexer produces, while syntactic precedence decides between parse alternatives over tokens already produced.",
38    },
39    RequiredDistinction {
40        concepts: ["binding", "assignment"],
41        reason: "A binding introduces a name for a value in a scope, while an assignment updates the value stored in a location that already exists.",
42    },
43    RequiredDistinction {
44        concepts: ["grammar.list", "grammar.linked-list"],
45        reason: "A list rule is a delimited sequence of items, while a linked list is built from pairs and may end in a dotted pair, so a linked list has no counterpart among plain sequences.",
46    },
47    RequiredDistinction {
48        concepts: ["grammar.string", "grammar.symbol"],
49        reason: "A string literal denotes text, while a symbol denotes an interned identifier that compares by identity, so the same characters read as different data.",
50    },
51    RequiredDistinction {
52        concepts: ["grammar.member", "grammar.ini.setting"],
53        reason: "An object member pairs a name with any nested value, while an INI setting is one key and value line of a section whose value is raw text.",
54    },
55    RequiredDistinction {
56        concepts: ["grammar.object", "grammar.racket.hash-table"],
57        reason: "An object maps names to values with no stated key comparison, while a Racket hash table maps any datum keys under its equality, so the two key sets differ.",
58    },
59    RequiredDistinction {
60        concepts: ["grammar.value", "grammar.datum"],
61        reason: "A value of a data grammar is data only, while a datum is the external representation of a program value, which the reader may also read as code.",
62    },
63    RequiredDistinction {
64        concepts: ["grammar.identifier", "grammar.identifier-name"],
65        reason: "An identifier rule accepts letters, digits and underscores that start with a letter, while an ECMAScript identifier name also accepts dollar signs, Unicode letters and escapes.",
66    },
67    RequiredDistinction {
68        concepts: ["grammar.document", "grammar.program"],
69        reason: "A document is the whole text of a data or document format, while a program is source text the reader reads as code.",
70    },
71    RequiredDistinction {
72        concepts: ["grammar.boolean-value", "grammar.true-value"],
73        reason: "A boolean value rule accepts both truth values, while a true value rule accepts the literal true alone, so one cannot replace the other.",
74    },
75    RequiredDistinction {
76        concepts: ["grammar.program", "grammar.module"],
77        reason: "A program is source text the reader reads as code as a whole, while a TypeScript module declaration is one statement that opens a namespace inside a program.",
78    },
79    RequiredDistinction {
80        concepts: ["grammar.true-constant", "grammar.true-value"],
81        reason: "A Lean true constant accepts the proposition True as well as the Boolean literal true, while a true value rule accepts the literal true alone.",
82    },
83    RequiredDistinction {
84        concepts: ["grammar.symbol", "grammar.keyword"],
85        reason: "A symbol may name a variable, while a keyword evaluates to itself and is never a variable.",
86    },
87    RequiredDistinction {
88        concepts: ["grammar.comment", "grammar.block-comment"],
89        reason: "A comment rule covers every comment its format allows, while a block comment runs between nesting delimiters, so it does not end at the end of its line.",
90    },
91];
92
93/// The foundation model pairs that stay distinct; the register records the reason for each.
94pub const REQUIRED_FOUNDATION_DISTINCTIONS: &[[&str; 2]] = &[
95    [
96        "integer-model.natural-number",
97        "integer-model.unbounded-integer",
98    ],
99    ["overflow-model.abort", "overflow-model.wrapping"],
100    [
101        "effect-model.abort",
102        "effect-model.panic-with-default-value",
103    ],
104    [
105        "universe-model.non-cumulative-sort-hierarchy",
106        "universe-model.cumulative-type-hierarchy",
107    ],
108    [
109        "proof-system.bounded-property-check",
110        "proof-system.lean-kernel",
111    ],
112    [
113        "logic-model.constructive-propositions",
114        "logic-model.classical-propositions",
115    ],
116];
117
118/// How two source spellings relate.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum CorrespondenceRelation {
121    /// Both spellings mean exactly one concept, and it is the same concept.
122    Shared,
123    /// Both spellings mean exactly one concept, and the concepts differ.
124    Distinct,
125    /// A spelling means more than one concept, so no correspondence follows from the spelling alone.
126    Ambiguous,
127    /// A spelling means no recorded concept.
128    Unknown,
129}
130
131impl CorrespondenceRelation {
132    /// The relation's name, the same string the JavaScript runtime reports.
133    #[must_use]
134    pub const fn as_str(self) -> &'static str {
135        match self {
136            Self::Shared => "shared",
137            Self::Distinct => "distinct",
138            Self::Ambiguous => "ambiguous",
139            Self::Unknown => "unknown",
140        }
141    }
142}
143
144/// The relation between two source spellings and what justifies it.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ConceptCorrespondence {
147    /// How the two spellings relate.
148    pub relation: CorrespondenceRelation,
149    /// The concepts and models the first spelling means.
150    pub first: Vec<String>,
151    /// The concepts and models the second spelling means.
152    pub second: Vec<String>,
153    /// The concept both spellings mean, when the relation is shared.
154    pub shared: Option<String>,
155    /// The definition of the shared concept, or the recorded reason two distinct concepts differ.
156    pub justification: Option<String>,
157    /// The kind of the recorded correspondence between two distinct foundation models.
158    pub correspondence: Option<String>,
159}
160
161/// A way the records fail to keep required distinctions or justify a shared concept.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ConceptDistinctionProblem {
164    /// The problem kind, the same string the JavaScript runtime reports, such as `indistinct-concepts`.
165    pub kind: &'static str,
166    /// The concept, model or pair the problem is about.
167    pub subject: String,
168    /// A readable explanation.
169    pub message: String,
170}
171
172/// One use of precedence in a grammar and the concept it expresses.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PrecedenceUse {
175    /// The rule the precedence appears in.
176    pub rule: String,
177    /// The precedence label, such as `prec=1` or `prec_left=2`.
178    pub label: String,
179    /// `grammar.lexical-precedence` inside a token, `grammar.syntactic-precedence` elsewhere.
180    pub concept: &'static str,
181}
182
183fn within_matches(id: &str, within: Option<&str>) -> bool {
184    within.is_none_or(|prefix| {
185        id == prefix
186            || id
187                .strip_prefix(prefix)
188                .is_some_and(|rest| rest.starts_with('.'))
189    })
190}
191
192/// Returns the concepts and foundation models a source spelling means, optionally only those under `within`.
193///
194/// A concept record matches an alias with the same source and name; a
195/// foundation model matches when its alias for the source lists the name among
196/// its comma-separated spellings.
197#[must_use]
198pub fn source_meanings_in(
199    records: &[ConceptRecord],
200    register: &FoundationRegister,
201    alias: &SourceAlias,
202    within: Option<&str>,
203) -> Vec<String> {
204    let concepts = records.iter().filter(|record| {
205        record
206            .source_aliases
207            .iter()
208            .any(|candidate| candidate.source == alias.source && candidate.name == alias.name)
209    });
210    let models = register.models.iter().filter(|model| {
211        model.source_aliases.iter().any(|candidate| {
212            candidate.source == alias.source
213                && candidate
214                    .name
215                    .split(", ")
216                    .any(|spelling| spelling == alias.name)
217        })
218    });
219    concepts
220        .map(|record| record.id.clone())
221        .chain(models.map(|model| model.id.clone()))
222        .filter(|id| within_matches(id, within))
223        .collect()
224}
225
226fn same_pair(pair: &[impl AsRef<str>], first: &str, second: &str) -> bool {
227    pair.len() == 2
228        && ((pair[0].as_ref() == first && pair[1].as_ref() == second)
229            || (pair[0].as_ref() == second && pair[1].as_ref() == first))
230}
231
232fn meaning_definition(
233    records: &[ConceptRecord],
234    register: &FoundationRegister,
235    id: &str,
236) -> Option<String> {
237    records
238        .iter()
239        .find(|record| record.id == id)
240        .map(|record| record.definition.clone())
241        .or_else(|| {
242            register
243                .models
244                .iter()
245                .find(|model| model.id == id)
246                .map(|model| model.definition.clone())
247        })
248}
249
250/// Relates two source spellings through the given records and foundation register.
251#[must_use]
252pub fn concept_correspondence_in(
253    records: &[ConceptRecord],
254    register: &FoundationRegister,
255    first: &SourceAlias,
256    second: &SourceAlias,
257    within: Option<&str>,
258) -> ConceptCorrespondence {
259    let first_meanings = source_meanings_in(records, register, first, within);
260    let second_meanings = source_meanings_in(records, register, second, within);
261    let mut result = ConceptCorrespondence {
262        relation: CorrespondenceRelation::Unknown,
263        first: first_meanings.clone(),
264        second: second_meanings.clone(),
265        shared: None,
266        justification: None,
267        correspondence: None,
268    };
269    if first_meanings.is_empty() || second_meanings.is_empty() {
270        return result;
271    }
272    if first_meanings.len() > 1 || second_meanings.len() > 1 {
273        result.relation = CorrespondenceRelation::Ambiguous;
274        return result;
275    }
276    let (one, other) = (&first_meanings[0], &second_meanings[0]);
277    if one == other {
278        result.relation = CorrespondenceRelation::Shared;
279        result.shared = Some(one.clone());
280        result.justification = meaning_definition(records, register, one);
281        return result;
282    }
283    result.relation = CorrespondenceRelation::Distinct;
284    result.justification = REQUIRED_CONCEPT_DISTINCTIONS
285        .iter()
286        .find(|entry| same_pair(&entry.concepts, one, other))
287        .map(|entry| entry.reason.to_owned())
288        .or_else(|| {
289            register
290                .distinctions
291                .iter()
292                .find(|entry| same_pair(&entry.models, one, other))
293                .map(|entry| entry.reason.clone())
294        });
295    result.correspondence = register
296        .correspondences
297        .iter()
298        .find(|entry| same_pair(&[&entry.from, &entry.to], one, other))
299        .map(|entry| entry.kind.clone());
300    result
301}
302
303/// Relates two source spellings through the embedded concept records and foundation register.
304#[must_use]
305pub fn concept_correspondence(
306    first: &SourceAlias,
307    second: &SourceAlias,
308    within: Option<&str>,
309) -> ConceptCorrespondence {
310    concept_correspondence_in(
311        concept_records(),
312        foundation_register(),
313        first,
314        second,
315        within,
316    )
317}
318
319fn shared_sources(aliases: &[SourceAlias]) -> usize {
320    aliases
321        .iter()
322        .map(|alias| alias.source.as_str())
323        .filter(|source| *source != "meta-language" && !source.ends_with(" grammar surface"))
324        .collect::<BTreeSet<_>>()
325        .len()
326}
327
328/// A justification is a sentence of at least three words.
329fn is_justification(text: &str) -> bool {
330    let text = text.trim();
331    text.ends_with('.') && text.split_whitespace().count() >= 3
332}
333
334/// Checks that the records keep every required distinction and justify every shared concept.
335///
336/// A required pair must name two recorded concepts (or models) with different
337/// definitions (or properties) that do not record one another as a former name
338/// or as what they represent, and every required foundation pair must carry a
339/// distinction with its reason in the register. A concept or model several
340/// sources share must state the meaning that justifies sharing it. The kinds
341/// match the JavaScript runtime's `checkConceptDistinctions`; an empty result
342/// means the distinctions hold.
343#[must_use]
344pub fn check_concept_distinctions(
345    records: &[ConceptRecord],
346    register: &FoundationRegister,
347) -> Vec<ConceptDistinctionProblem> {
348    let mut problems = Vec::new();
349    let mut report = |kind, subject: &str, message: String| {
350        problems.push(ConceptDistinctionProblem {
351            kind,
352            subject: subject.to_owned(),
353            message,
354        });
355    };
356    for entry in REQUIRED_CONCEPT_DISTINCTIONS {
357        let subject = entry.concepts.join(" / ");
358        let found: Vec<_> = entry
359            .concepts
360            .iter()
361            .map(|id| records.iter().find(|record| record.id == *id))
362            .collect();
363        for (id, record) in entry.concepts.iter().zip(&found) {
364            if record.is_none() {
365                report(
366                    "unknown-concept",
367                    &subject,
368                    format!("{subject} must stay distinct, but {id} is not recorded"),
369                );
370            }
371        }
372        let [Some(first), Some(second)] = found[..] else {
373            continue;
374        };
375        if first.definition.trim().to_lowercase() == second.definition.trim().to_lowercase() {
376            report(
377                "indistinct-concepts",
378                &subject,
379                format!("{subject} record the same definition, which merges two meanings"),
380            );
381        }
382        for (record, other) in [(first, second), (second, first)] {
383            if record.former_names.contains(&other.id)
384                || record.represents.as_deref() == Some(other.id.as_str())
385            {
386                report(
387                    "conflated-distinction",
388                    &subject,
389                    format!(
390                        "{} records {} as itself, which merges two meanings",
391                        record.id, other.id
392                    ),
393                );
394            }
395        }
396    }
397    for pair in REQUIRED_FOUNDATION_DISTINCTIONS {
398        let subject = pair.join(" / ");
399        let found: Vec<_> = pair
400            .iter()
401            .map(|id| register.models.iter().find(|model| model.id == *id))
402            .collect();
403        for (id, model) in pair.iter().zip(&found) {
404            if model.is_none() {
405                report(
406                    "unknown-model",
407                    &subject,
408                    format!("{subject} must stay distinct, but {id} is not recorded"),
409                );
410            }
411        }
412        if let [Some(first), Some(second)] = found[..]
413            && first.properties == second.properties
414        {
415            report(
416                "indistinct-concepts",
417                &subject,
418                format!("{subject} record the same properties, which merges two meanings"),
419            );
420        }
421        let recorded = register.distinctions.iter().any(|entry| {
422            same_pair(&entry.models, pair[0], pair[1]) && is_justification(&entry.reason)
423        });
424        if !recorded {
425            report(
426                "unrecorded-distinction",
427                &subject,
428                format!("the foundation register records no reason why {subject} differ"),
429            );
430        }
431    }
432    for record in records {
433        if shared_sources(&record.source_aliases) > 1
434            && (!is_justification(&record.definition) || record.constraints.is_empty())
435        {
436            report(
437                "unjustified-sharing",
438                &record.id,
439                format!(
440                    "{} is shared by several sources but states no meaning that justifies the correspondence",
441                    record.id
442                ),
443            );
444        }
445    }
446    for model in &register.models {
447        if shared_sources(&model.source_aliases) > 1
448            && (!is_justification(&model.definition) || model.properties.is_empty())
449        {
450            report(
451                "unjustified-sharing",
452                &model.id,
453                format!(
454                    "{} is shared by several languages but states no meaning that justifies the correspondence",
455                    model.id
456                ),
457            );
458        }
459    }
460    problems
461}
462
463fn is_precedence_label(label: &str) -> bool {
464    ["prec", "prec_left", "prec_right", "prec_dynamic"]
465        .iter()
466        .any(|name| {
467            label == *name
468                || label
469                    .strip_prefix(name)
470                    .is_some_and(|rest| rest.starts_with('='))
471        })
472}
473
474fn collect_precedence(
475    rule: &str,
476    expr: &GrammarExpr,
477    in_token: bool,
478    uses: &mut Vec<PrecedenceUse>,
479) {
480    match expr {
481        GrammarExpr::Capture { label, expr } => {
482            let label = label.as_deref().unwrap_or_default();
483            if is_precedence_label(label) {
484                let lexical = in_token && !label.starts_with("prec_dynamic");
485                uses.push(PrecedenceUse {
486                    rule: rule.to_owned(),
487                    label: label.to_owned(),
488                    concept: if lexical {
489                        "grammar.lexical-precedence"
490                    } else {
491                        "grammar.syntactic-precedence"
492                    },
493                });
494            }
495            let token = in_token || label == "token" || label == "immediate_token";
496            collect_precedence(rule, expr, token, uses);
497        }
498        GrammarExpr::Choice { alternatives, .. } => {
499            for alternative in alternatives {
500                collect_precedence(rule, alternative, in_token, uses);
501            }
502        }
503        GrammarExpr::Sequence(items) => {
504            for item in items {
505                collect_precedence(rule, item, in_token, uses);
506            }
507        }
508        GrammarExpr::Optional(item)
509        | GrammarExpr::ZeroOrMore(item)
510        | GrammarExpr::OneOrMore(item)
511        | GrammarExpr::Repeat { expr: item, .. }
512        | GrammarExpr::And(item)
513        | GrammarExpr::Not(item) => collect_precedence(rule, item, in_token, uses),
514        _ => {}
515    }
516}
517
518/// Returns every precedence use in `grammar` with the concept it expresses.
519///
520/// Precedence inside a token rule or a `token` capture decides between tokens
521/// (lexical precedence); anywhere else, and dynamic precedence everywhere, it
522/// decides between parse alternatives (syntactic precedence). Both runtimes
523/// read tree-sitter's `prec` the same way, so the two never merge.
524#[must_use]
525pub fn grammar_precedence_concepts(grammar: &Grammar) -> Vec<PrecedenceUse> {
526    let mut uses = Vec::new();
527    for rule in grammar.rules() {
528        collect_precedence(
529            rule.name(),
530            rule.expr(),
531            rule.kind() == RuleKind::Token,
532            &mut uses,
533        );
534    }
535    uses
536}