typir
Version:
General purpose type checking library
361 lines (322 loc) • 16.1 kB
text/typescript
/******************************************************************************
* Copyright 2025 TypeFox GmbH
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
******************************************************************************/
import { TypeGraphListener } from '../graph/type-graph.js';
import { Type } from '../graph/type-node.js';
import { TypirSpecifics, TypirServices } from '../typir.js';
import { removeFromArray, toArray, toArrayWithValue } from './utils.js';
export interface RuleOptions {
/**
* If a rule is associated with a language key, the rule will be executed only for language nodes, which have this language key,
* in order to improve the runtime performance.
* In case of multiple language keys, the rule will be applied to all language nodes having ones of these language keys.
* Rules without a language key ('undefined') are executed for all language nodes.
*/
languageKey: string | string[] | undefined;
/**
* An optional type, if the new rule is dedicated for exactly this type.
* If the given type is removed from the type system, this rule will be automatically removed as well (for all language keys).
* In case of multiple types, this rule will be removed, after all types are removed.
* In case of 'undefined', the rule will never be automatically removed.
*/
boundToType: Type | Type[] | undefined;
}
// corresponding information in a slightly different structure, which is easier to handle internally
interface InternalRuleOptions {
languageKeyUndefined: boolean;
languageKeys: string[];
boundToTypes: Type[];
}
export interface RuleCollectorListener<RuleType> {
onAddedRule(rule: RuleType, diffOptions: RuleOptions): void;
onRemovedRule(rule: RuleType, diffOptions: RuleOptions): void;
}
export class RuleRegistry<RuleType, Specifics extends TypirSpecifics> implements TypeGraphListener {
/**
* language node type --> rules
* Improves the look-up of related rules, when doing type for a concrete language node.
* All rules are registered at least once in this map, since rules without dedicated language key are registered to 'undefined'. */
protected readonly languageTypeToRules: Map<string|undefined, RuleType[]> = new Map();
/**
* type identifier --> -> rules
* Improves the look-up for rules which are bound to types, when these types are removed.
* Only rules which are bound to at least one type in this map, types bound to no types are missing in this map. */
protected readonly typirTypeToRules: Map<string, RuleType[]> = new Map();
/**
* rule --> its collected options
* Contains the current set of all options for an rule. */
protected readonly ruleToOptions: Map<RuleType, InternalRuleOptions> = new Map();
/** Collects all unique rules, lazily managed. */
protected readonly uniqueRules: Set<RuleType> = new Set();
protected readonly listeners: Array<RuleCollectorListener<RuleType>> = [];
constructor(services: TypirServices<Specifics>) {
services.infrastructure.Graph.addListener(this);
}
getRulesByLanguageKey(languageKey: string | undefined): RuleType[] {
const store = this.languageTypeToRules.get(languageKey);
if (store === undefined) {
return [];
}
return store;
}
/** Unique set of all registered rules. */
getUniqueRules(): Set<RuleType> {
if (this.uniqueRules.size <= 0) {
// lazily fill the set of unique rules
Array.from(this.languageTypeToRules.values()).flatMap(v => v).forEach(v => this.uniqueRules.add(v));
}
return this.uniqueRules;
}
isEmpty(): boolean {
return this.languageTypeToRules.size <= 0;
}
getNumberUniqueRules(): number {
return this.getUniqueRules().size;
}
protected getRuleOptions(options?: Partial<RuleOptions>): RuleOptions {
return {
// default values ...
languageKey: undefined,
boundToType: undefined,
// ... overridden by the actual options:
...options,
};
}
addRule(rule: RuleType, givenOptions?: Partial<RuleOptions>): void {
const newOptions = this.getRuleOptions(givenOptions);
const languageKeyUndefined: boolean = newOptions.languageKey === undefined;
const languageKeys: string[] = toArray(newOptions.languageKey, { newArray: true });
const existingOptions = this.ruleToOptions.get(rule);
const diffOptions: RuleOptions = {
...newOptions,
languageKey: [], // empty for now, added keys will be added later
boundToType: [],
};
let added = false; // remember whether the rule is really new
// register the rule with the key(s) of the language node
if (languageKeyUndefined) {
// register this rule for 'undefined'
if (existingOptions?.languageKeyUndefined) {
// nothing to do, since this rule is already registered for 'undefined'
} else {
// since the rule shall be registered for 'undefined', remove all existing specific language keys
this.removeRule(rule, { languageKey: existingOptions?.languageKeys ?? [] });
// register this rule for 'undefined'
let rules = this.languageTypeToRules.get(undefined);
if (rules === undefined) {
rules = [];
this.languageTypeToRules.set(undefined, rules);
}
rules.push(rule);
if (existingOptions !== undefined) {
existingOptions.languageKeyUndefined = true;
}
added = true;
diffOptions.languageKey = undefined;
}
} else {
// register this rule for some language keys
if (existingOptions?.languageKeyUndefined) {
// don't add the new language keys, since this rule is already registered for 'undefined'
} else {
// add some more language keys
for (const key of languageKeys) {
let rules = this.languageTypeToRules.get(key);
if (rules === undefined) {
rules = [];
this.languageTypeToRules.set(key, rules);
}
if (existingOptions === undefined) {
// this rule is unknown until now
rules.push(rule);
added = true;
diffOptions.languageKey = toArrayWithValue(key, diffOptions.languageKey);
} else {
if (existingOptions.languageKeys.includes(key)) {
// this rule is already registered with this language key => do nothing
} else {
// this rule is known, but not registered for the current language key yet
rules.push(rule);
existingOptions.languageKeys.push(key);
added = true;
diffOptions.languageKey = toArrayWithValue(key, diffOptions.languageKey);
}
}
}
}
}
// register the rule to Typir types in order to easily remove them together with removed types
for (const boundToType of toArray(newOptions.boundToType)) {
const typeKey = this.getBoundToTypeKey(boundToType);
let rules = this.typirTypeToRules.get(typeKey);
if (rules === undefined) {
rules = [];
this.typirTypeToRules.set(typeKey, rules);
}
if (existingOptions === undefined) {
// this rule is unknown until now
rules.push(rule);
diffOptions.boundToType = toArrayWithValue(boundToType, diffOptions.boundToType);
added = true;
} else {
if (existingOptions.boundToTypes.includes(boundToType)) {
// this rule is already bound to this type => do nothing
} else {
// this rule is known, but not bound to the current type yet
existingOptions.boundToTypes.push(boundToType);
rules.push(rule);
diffOptions.boundToType = toArrayWithValue(boundToType, diffOptions.boundToType);
added = true;
}
}
}
if (existingOptions === undefined) {
// no options yet => use the new options
this.ruleToOptions.set(rule, {
languageKeyUndefined: languageKeyUndefined,
languageKeys: languageKeys,
boundToTypes: toArray(newOptions.boundToType, { newArray: true }),
});
} else {
// the existing options are already updated above
}
// update the set of unique rules
if (this.uniqueRules.size >= 1) {
this.uniqueRules.add(rule);
} else {
// otherwise the set is populated on the next request
}
// inform all listeners about the new rule
if (added) {
this.listeners.forEach(listener => listener.onAddedRule(rule, diffOptions));
}
}
removeRule(rule: RuleType, optionsToRemove?: Partial<RuleOptions>): void {
const existingOptions = this.ruleToOptions.get(rule);
if (existingOptions === undefined) { // these options need to be updated (or completely removed at the end)
return; // the rule is unknown here => nothing to do
}
const languageKeyUndefined: boolean = optionsToRemove ? (optionsToRemove.languageKey === undefined) : true;
const languageKeys: string[] = toArray(optionsToRemove?.languageKey, { newArray: true });
const diffOptions: RuleOptions = {
// ... maybe more options in the future ...
languageKey: [], // empty/nothing
boundToType: [], // empty/nothing
};
let removed = false;
// update 'language keys'
if (languageKeyUndefined) {
// deregister the rule for 'undefined'
if (existingOptions.languageKeyUndefined) {
const result = this.deregisterRuleForLanguageKey(rule, undefined);
if (result) {
removed = true;
diffOptions.languageKey = undefined;
}
} else {
// deregister the rule for all existing language keys
languageKeys.push(...existingOptions.languageKeys);
}
existingOptions.languageKeyUndefined = false;
}
if (languageKeys.length >= 1) {
// remove the rule for some language keys
if (existingOptions.languageKeyUndefined) {
// since the rule is registered for 'undefined', i.e. all language keys, don't remove some language keys here
} else {
for (const key of languageKeys) {
const result1 = this.deregisterRuleForLanguageKey(rule, key);
const result2 = removeFromArray(key, existingOptions.languageKeys); // update existing options
if (result1 !== result2) {
throw new Error();
}
if (result1) {
removed = true;
diffOptions.languageKey = toArrayWithValue(key, diffOptions.languageKey);
}
}
}
}
// update 'bounded types'
for (const boundToType of toArray(optionsToRemove?.boundToType)) {
const typeKey = this.getBoundToTypeKey(boundToType);
const rules = this.typirTypeToRules.get(typeKey);
if (rules) {
const result = removeFromArray(rule, rules);
if (result) {
removed = true;
diffOptions.boundToType = toArrayWithValue(boundToType, diffOptions.boundToType);
removeFromArray(boundToType , existingOptions.boundToTypes); // update existing options
if (rules.length <= 0) { // remove empty entries
this.typirTypeToRules.delete(typeKey);
}
}
}
}
// if the rule is not relevant anymore, clear the options map
if (existingOptions.languageKeyUndefined === false && existingOptions.languageKeys.length <= 0) {
this.ruleToOptions.delete(rule);
}
// update the set of unique rules
this.uniqueRules.clear(); // the set needs to be populated on the next request
// inform listeners
if (removed) {
this.listeners.forEach(listener => listener.onRemovedRule(rule, diffOptions));
}
}
protected deregisterRuleForLanguageKey(rule: RuleType, languageKey: string | undefined): boolean {
const rules = this.languageTypeToRules.get(languageKey);
if (rules) {
const result = removeFromArray(rule, rules);
if (rules.length <= 0) { // remove empty entries
this.languageTypeToRules.delete(languageKey);
}
return result;
}
return false;
}
protected getBoundToTypeKey(boundToType?: Type): string {
return boundToType?.getIdentifier() ?? '';
}
/* Get informed about deleted types in order to remove rules which are bound to them. */
onRemovedType(type: Type, _key: string): void {
const typeKey = this.getBoundToTypeKey(type); // TODO only if "typeKey === _key" ?? this needs to be double-checked when making Alias types explicit!
const entriesToRemove = this.typirTypeToRules.get(typeKey);
if (entriesToRemove) {
this.typirTypeToRules.delete(typeKey);
// for each rule which was bound to the removed type:
for (const ruleToRemove of entriesToRemove) {
const existingOptions = this.ruleToOptions.get(ruleToRemove)!;
const removed = removeFromArray(type, existingOptions.boundToTypes);
if (removed) {
if (existingOptions.boundToTypes.length <= 0) {
// this rule is not bound to any existing type anymore => remove this rule completely
this.removeRule(ruleToRemove, {
// ... maybe additional properties in the future?
// boundToType: there are no bounded types anymore!
languageKey: existingOptions.languageKeyUndefined ? undefined : existingOptions.languageKeys,
});
} else {
// inform listeners about removed rules
this.listeners.forEach(listener => listener.onRemovedRule(ruleToRemove, {
...existingOptions,
languageKey: existingOptions.languageKeyUndefined ? undefined : existingOptions.languageKeys,
boundToType: type,
// Note that more future options might be unknown here ... (let's hope, they are not relevant here)
}));
}
} else {
throw new Error('Removed type does not exist here');
}
}
}
}
addListener(listener: RuleCollectorListener<RuleType>): void {
this.listeners.push(listener);
}
removeListener(listener: RuleCollectorListener<RuleType>): void {
removeFromArray(listener, this.listeners);
}
}