UNPKG

@adguard/aglint

Version:

Universal adblock filter list linter.

799 lines (796 loc) 35 kB
/* * AGLint v3.0.0 (build date: Wed, 21 May 2025 13:24:14 GMT) * (c) 2025 AdGuard Software Ltd. * Released under the MIT license * https://github.com/AdguardTeam/AGLint#readme */ import { assert } from 'superstruct'; import cloneDeep from 'clone-deep'; import { PositionProvider, FilterListParser, RuleCategory, CommentRuleType, AdblockSyntax } from '@adguard/agtree'; import { defaultParserOptions } from '@adguard/agtree/parser'; import { FilterListGenerator } from '@adguard/agtree/generator'; import { defaultLinterConfig, mergeConfigsReverse, mergeConfigs, linterRulesSchema } from './config.js'; import { defaultLinterRules } from './rules/index.js'; import { ConfigCommentType } from './inline-config.js'; import { isSeverity, getSeverity, SEVERITY } from './severity.js'; import { validateLinterConfig } from './config-validator.js'; import { defaultConfigPresets } from './config-presets/index.js'; import { isUndefined, isNull } from '../utils/type-guards.js'; import { CssCache } from './helpers/css-cache.js'; import { getErrorMessage } from '../utils/error.js'; import { isErrorContainingOffset } from './helpers/css-errors.js'; import { parseCss } from './helpers/css-parse.js'; /** * @file AGLint core */ /** * Core linter logic */ class Linter { /** * A map of rule names to `LinterRule` objects */ rules = new Map(); /** * The linter configuration */ config = defaultLinterConfig; /** * Config presets */ configPresets = new Map(); /** * Creates a new linter instance. * * @param defaultRules Add default linter rules and config presets to the linter * (by default it adds them) * @param config Linter config to use (by default it uses the default config) */ constructor(defaultRules = true, config = defaultLinterConfig) { if (defaultRules) { this.addDefaultRules(); this.addDefaultConfigPresets(); } this.setConfig(config); } /** * Adds all default rules to the linter. */ addDefaultRules() { for (const [name, rule] of defaultLinterRules) { this.addRule(name, rule); } } /** * Adds all default config presets to the linter. */ addDefaultConfigPresets() { for (const [name, config] of defaultConfigPresets) { this.addConfigPreset(name, config); } } /** * Sets the config for a given rule. It just overrides the default config. * * @param ruleName The name of the rule to set the config for * @param ruleConfig The config to set * @throws If the rule doesn't exist * @throws If the rule severity / config is invalid * @throws If the rule doesn't support config */ setRuleConfig(ruleName, ruleConfig) { const entry = this.rules.get(ruleName); if (!entry) { throw new Error(`Rule "${ruleName}" doesn't exist`); } const severity = Array.isArray(ruleConfig) ? ruleConfig[0] : ruleConfig; let config; if (Array.isArray(ruleConfig) && ruleConfig.length > 1) { const rest = ruleConfig.slice(1); config = rest.length === 1 ? rest[0] : rest; } if (!isSeverity(severity)) { throw new Error(`Invalid severity "${severity}" for rule "${ruleName}"`); } entry.severityOverride = getSeverity(severity); if (config !== undefined) { if (!entry.rule.meta.config) { throw new Error(`Rule "${ruleName}" doesn't support config`); } try { assert(config, entry.rule.meta.config.schema); } catch (err) { throw new Error(`Invalid config for rule "${ruleName}": ${err.message}`); } entry.configOverride = config; } this.rules.set(ruleName, entry); } /** * This method applies the configuration "rules" part to the linter. * * @param rulesConfig Rules config object */ applyRulesConfig(rulesConfig) { for (const [ruleName, ruleConfig] of Object.entries(rulesConfig)) { this.setRuleConfig(ruleName, ruleConfig); } } /** * Gets the linter configuration. * * @returns The linter configuration */ getConfig() { return cloneDeep(this.config); } /** * Adds a new config preset to the linter. * * @param name Config preset name, e.g. "aglint:recommended" * @param config Related config object * @throws If the config preset already exists */ addConfigPreset(name, config) { // Don't allow to override existing config presets if (this.configPresets.has(name)) { throw new Error(`Config preset "${name}" already exists`); } // Validate the config validateLinterConfig(config); this.configPresets.set(name, config); } /** * Applies the config presets to the config object (extends the config * with the given presets if they exist). * * @param config Config object * @returns Extended config object * @throws If the config preset doesn't exist * @throws If the config is invalid */ applyConfigExtensions(config) { // Validate the provided config before applying the config presets validateLinterConfig(config); // Clone the provided config object to avoid any side effects let clonedConfig = cloneDeep(config); // If the provided config has "extends" property, // presets should be merged in the same order they are specified in the "extends" array let mergedPresets = {}; if (clonedConfig.extends) { for (const presetName of clonedConfig.extends) { const preset = this.configPresets.get(presetName); // Preset should exist if (!preset) { throw new Error(`Config preset "${presetName}" doesn't exist`); } // TODO: Allow "extends" in config presets (recursively)? // ! If the config preset extends itself, we'll get an infinite loop // Merge the current preset with the previously merged presets mergedPresets = mergeConfigsReverse(mergedPresets, preset); } // Remove "extends" property from the cloned config, because we don't need it anymore delete clonedConfig.extends; } // Override presets with the provided config clonedConfig = mergeConfigsReverse(mergedPresets, clonedConfig); // Validate the prepared config validateLinterConfig(clonedConfig); return clonedConfig; } /** * Sets the linter configuration. If `reset` is set to `true`, all rule * configurations are reset to their default values (removing overrides). * * @param config Core linter configuration * @param reset Whether to reset all rule configs * @throws If any of the config presets doesn't exist * @throws If the rule config is invalid in any way */ setConfig(config, reset = true) { // Merge the given config with the default config, but before that // don't forget apply the config presets const newConfig = mergeConfigs(defaultLinterConfig, this.applyConfigExtensions(config)); // Set the new config this.config = newConfig; // Reset all rule configs if (reset) { for (const entry of this.rules.values()) { entry.configOverride = undefined; // By default, severity is set to 0 (which means "off") entry.severityOverride = 0; } } if (newConfig.rules) { this.applyRulesConfig(newConfig.rules); } } /** * Adds a new rule to the linter. * * @param name The name of the rule * @param rule The rule itself * @throws If the rule name is already taken */ addRule(name, rule) { this.addRuleEx(name, { rule, // Initialize storage as empty object storage: {}, }); } /** * Adds a new rule to the linter, but you can specify the rule data. * * @param name The name of the rule * @param data The rule data, see `LinterRuleData` interface for more details * @throws If the rule name is already taken * @throws If the rule severity is invalid * @throws If the rule config is invalid */ addRuleEx(name, data) { if (this.rules.has(name)) { throw new Error(`Rule with name "${name}" already exists`); } const clone = cloneDeep(data); if (clone.severityOverride) { // Validate severity if (!isSeverity(clone.severityOverride)) { throw new Error(`Invalid severity "${clone.severityOverride}" for rule "${name}"`); } // Convert to number clone.severityOverride = getSeverity(clone.severityOverride); } if (clone.configOverride) { if (!clone.rule.meta.config) { throw new Error(`Rule "${name}" doesn't support config`); } else { try { assert(clone.configOverride, clone.rule.meta.config.schema); } catch (err) { throw new Error(`Invalid config for rule "${name}": ${err.message}`); } } } // Add rule to the repository this.rules.set(name, clone); } /** * Resets default config for the rule with the specified name. * * @param name The name of the rule * @throws If the rule doesn't exist * @throws If the rule doesn't support config */ resetRuleConfig(name) { // Find the rule const entry = this.rules.get(name); // Check if the rule exists if (!entry) { throw new Error(`Rule with name "${name}" doesn't exist`); } if (!entry.rule.meta.config) { throw new Error(`Rule "${name}" doesn't support config`); } // Set the config to undefined, so the default config will be used next time entry.severityOverride = undefined; entry.configOverride = undefined; } /** * Gets the current config for the rule with the specified name. * * @param name The name of the rule * @returns The currently active config for the rule. If no override is set, * the default config is returned. * @throws If the rule doesn't exist * @throws If the rule doesn't support config */ getRuleConfig(name) { // Find the rule const entry = this.rules.get(name); // Check if the rule exists if (!entry) { throw new Error(`Rule with name "${name}" doesn't exist`); } if (!entry.rule.meta.config) { throw new Error(`Rule "${name}" doesn't support config`); } return [ isSeverity(entry.severityOverride) ? entry.severityOverride : entry.rule.meta.severity, entry.configOverride || entry.rule.meta.config.default, ]; } /** * Returns the `LinterRule` object with the specified name. * * @param name - The name of the rule * @returns The `LinterRule` object, or `undefined` if no such rule exists */ getRule(name) { return cloneDeep(this.rules.get(name)?.rule); } /** * Returns the map of all rules in the repository. * * @returns The map of rule names to `LinterRule` objects */ getRules() { return cloneDeep(this.rules); } /** * Returns whether a rule with the specified name exists in the repository. * * @param name - The name of the rule * @returns `true` if the rule exists, `false` otherwise */ hasRule(name) { return this.rules.has(name); } /** * Removes a rule from the repository. * * @param name - The name of the rule */ removeRule(name) { if (!this.rules.has(name)) { throw new Error(`Rule with name "${name}" does not exist`); } this.rules.delete(name); } /** * Disables a rule by name. * * @param name - The name of the rule * @throws If the rule does not exist */ disableRule(name) { const entry = this.rules.get(name); // Check if the rule exists if (!entry) { throw new Error(`Rule with name "${name}" does not exist`); } entry.severityOverride = SEVERITY.off; this.rules.set(name, entry); } /** * Enables a rule * * @param name - The name of the rule * @throws If the rule does not exist */ enableRule(name) { const entry = this.rules.get(name); // Check if the rule exists if (!entry) { throw new Error(`Rule with name "${name}" does not exist`); } entry.severityOverride = undefined; this.rules.set(name, entry); } /** * Returns whether a rule is disabled. * * @param name - The name of the rule * @returns `true` if the rule is disabled, `false` otherwise */ isRuleDisabled(name) { const entry = this.rules.get(name); if (!entry) { return false; } const severity = isSeverity(entry.severityOverride) ? entry.severityOverride : entry.rule.meta.severity; // Don't forget to convert severity to number (it can be a string, // if it was set by the user, and it's can be confusing) return getSeverity(severity) === SEVERITY.off; } /** * Lints the list of rules (typically this is the content of a filter list). * * @param content - Filter list content * @param fix - Include fixes in the result. Please note that if more than one fix * is available for a single problem, then the line will be skipped. * @returns Linter result */ lint(content, fix = false) { // Prepare linting result const result = { problems: [], warningCount: 0, errorCount: 0, fatalErrorCount: 0, }; const positionProvider = new PositionProvider(content); let isDisabled = false; // A set of linter rule names that are disabled on the next line const nextLineDisabled = new Set(); let isDisabledForNextLine = false; // A set of linter rule names that are enabled on the next line const nextLineEnabled = new Set(); let isEnabledForNextLine = false; // Store the actual line number here for the context object let actualLine = 0; // Store the actual rule here for the context object let actualAdblockRuleAst; let actualAdblockRuleRaw; // Shared CSS cache to avoid parsing the same CSS multiple times const cssCache = new CssCache(); /** * Invokes an event for all rules. This function is only used internally * by the actual linting, so we define it here. * * The context is dependent on the actual linting environment, so we create * a new context object for each event within this function. * * @param event - The event to invoke (e.g. `onRule`) */ const invokeEvent = (event) => { for (const [name, data] of this.rules) { // If the rule is disabled, skip it if ((this.isRuleDisabled(name) // rule is disabled at config level || nextLineDisabled.has(name)) // or rule is disabled for the next line && !nextLineEnabled.has(name) // and rule is not enabled for the next line ) { continue; } // Validate rule configuration (if it exists) if (data.rule.meta.config) { assert(data.configOverride || data.rule.meta.config.default, data.rule.meta.config.schema); } // eslint-disable-next-line @typescript-eslint/no-loop-func const report = (problem) => { let severity = getSeverity(data.rule.meta.severity); if (!nextLineEnabled.has(name)) { // rely on the result of network rules modifiers validation; // see src/linter/rules/invalid-modifiers.ts if (isSeverity(problem.customSeverity)) { severity = getSeverity(problem.customSeverity); } else if (isSeverity(data.severityOverride)) { severity = getSeverity(data.severityOverride); } } // Default problem location: whole line let position = { startLine: actualLine, startColumn: 0, endLine: actualLine, endColumn: actualAdblockRuleRaw.length, }; if (problem.position) { position = problem.position; } else if (!isUndefined(problem.node) && !isUndefined(problem.node.start) && !isUndefined(problem.node.end)) { let startOffset = problem.node.start; let endOffset = problem.node.end; const { relativeNodeStartOffset, relativeNodeEndOffset } = problem; if (!isUndefined(relativeNodeStartOffset)) { startOffset = Math.min(problem.node.start + relativeNodeStartOffset, problem.node.end); } if (!isUndefined(relativeNodeEndOffset)) { endOffset = Math.min(problem.node.start + relativeNodeEndOffset, problem.node.end); } const start = positionProvider.convertOffsetToPosition(startOffset); const end = positionProvider.convertOffsetToPosition(endOffset); if (!isNull(start) && !isNull(end)) { position = { startLine: start.line, startColumn: start.column - 1, endLine: end.line, endColumn: end.column - 1, }; } } result.problems.push({ rule: name, severity, message: problem.message, position, fix: problem.fix, }); // Update problem counts switch (severity) { case SEVERITY.warn: result.warningCount += 1; break; case SEVERITY.error: result.errorCount += 1; break; case SEVERITY.fatal: result.fatalErrorCount += 1; break; } }; const genericContext = Object.freeze({ // Deep copy of the linter configuration getLinterConfig: () => { return { ...this.config }; }, // Currently linted filter list content getFilterListContent: () => content, fixingEnabled: () => fix, // Storage reference storage: data.storage, // Rule configuration config: data.configOverride || data.rule.meta.config?.default, report, // eslint-disable-next-line @typescript-eslint/no-loop-func getCssNode: (rawValueNode, context) => { const rawCss = rawValueNode.value; const possibleCssNode = cssCache.get(context, rawCss); const reportError = (error) => { const problem = { // eslint-disable-next-line max-len message: `Cannot parse CSS due to the following error: ${getErrorMessage(error)}`, node: rawValueNode, }; if (isErrorContainingOffset(error)) { problem.relativeNodeStartOffset = error.offset; } report(problem); }; if (!isUndefined(possibleCssNode)) { if (possibleCssNode instanceof Error) { reportError(possibleCssNode); return null; } return possibleCssNode; } let cssNode; try { // https://github.com/csstree/csstree/blob/master/docs/parsing.md#parsesource-options cssNode = parseCss(rawCss, context); // eslint-disable-next-line @typescript-eslint/no-explicit-any cssCache.set(context, rawCss, cssNode); } catch (error) { cssCache.set(context, rawCss, error); if (error instanceof Error) { reportError(error); } return null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any return cssNode; }, }); // Call the proper event handler if (event !== 'onRule') { const handler = data.rule.events[event]; if (handler) { handler(genericContext); } } else { const handler = data.rule.events.onRule; if (handler) { handler(Object.freeze({ ...genericContext, // eslint-disable-next-line @typescript-eslint/no-loop-func getActualLine: () => actualLine, // Currently iterated adblock rule // eslint-disable-next-line @typescript-eslint/no-loop-func getActualAdblockRuleAst: () => actualAdblockRuleAst, // Currently iterated adblock rule // eslint-disable-next-line @typescript-eslint/no-loop-func getActualAdblockRuleRaw: () => actualAdblockRuleRaw, })); } } } }; // Invoke onStartFilterList event before parsing the filter list invokeEvent('onStartFilterList'); // Parse the filter list const filterList = FilterListParser.parse(content, { ...defaultParserOptions, tolerant: true, }); // Iterate over all filter list adblock rules filterList.children.forEach((ast, index) => { // Update actual line number for the context object actualLine = index + 1; // Process the line const code = (() => { // Invalid rules if (ast.category === RuleCategory.Invalid) { // If the linter is actually disabled, skip the error reporting if ((isDisabled || isDisabledForNextLine) && !isEnabledForNextLine) { return 0; } // If an error occurs during parsing, it means that the rule is invalid, // that is, it could not be parsed for some reason. This is a fatal error, // since the linter rules can only accept AST. // AdblockSyntaxError is a special error type that is thrown by the parser // when it encounters a syntax error. In this case, we can get the position // of the error from the error object. // Otherwise, we assume that the error occurred at the beginning of the line // and we report the error for the entire line (from the beginning to the end). /* eslint-disable @typescript-eslint/no-non-null-assertion */ const start = positionProvider.convertOffsetToPosition(ast.error.start); const end = positionProvider.convertOffsetToPosition(ast.error.end); const position = { startLine: start.line, startColumn: start.column - 1, endLine: end.line, endColumn: end.column - 1, }; /* eslint-enable @typescript-eslint/no-non-null-assertion */ // Store the error in the result object result.problems.push({ severity: SEVERITY.fatal, message: `Cannot parse adblock rule due to the following error: ${ast.error.message}`, position, }); // Don't forget to increase the fatal error count when parsing fails result.fatalErrorCount += 1; } else { // Handle inline config comments if (ast.category === RuleCategory.Comment && ast.type === CommentRuleType.ConfigCommentRule) { // If inline config is not allowed in the linter configuration, // simply skip the comment processing if (!this.config.allowInlineConfig) { return 0; } // Process the inline config comment switch (ast.command.value) { case ConfigCommentType.Main: { if (ast.params && ast.params.type === 'ConfigNode') { assert(ast.params.value, linterRulesSchema); this.config = mergeConfigs(this.config, { rules: ast.params.value, }); this.applyRulesConfig(ast.params.value); } break; } case ConfigCommentType.Disable: { if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { if (param) { this.disableRule(param.value); } } break; } isDisabled = true; break; } case ConfigCommentType.Enable: { if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { if (param) { this.enableRule(param.value); } } break; } isDisabled = false; break; } case ConfigCommentType.DisableNextLine: { // Disable specific rules for the next line if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { if (param) { nextLineDisabled.add(param.value); } } } else { // Disable all rules for the next line isDisabledForNextLine = true; } break; } case ConfigCommentType.EnableNextLine: { // Enable specific rules for the next line if (ast.params && ast.params.type === 'ParameterList') { for (const param of ast.params.children) { if (param) { nextLineEnabled.add(param.value); } } } else { // Disable all rules for the next line isEnabledForNextLine = true; } break; } } // The config comment has been processed, there is nothing more to do with the line // But we need to return 1, because we processed an inline config comment return 1; } // If the linter is actually disabled, skip the rule processing. // It is important to do this check here, because we need to // process the inline config comments even if the linter is disabled // (in this way we could detect the `enable` command, for example). if ((isDisabled || isDisabledForNextLine) && !isEnabledForNextLine) { return 0; } // Deep copy of the line data actualAdblockRuleAst = { ...ast }; // It is safe to use the `!` operator here, because we know that the `raws` property exists, // since we configured the parser to return the raw data as well. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion actualAdblockRuleRaw = ast.raws.text; // Invoke onRule event for all rules (process actual adblock rule) invokeEvent('onRule'); // Check if filter list has Agent type comment // and if so, it will override the syntax property set in the config if (ast.category === RuleCategory.Comment && ast.type === CommentRuleType.AgentCommentRule) { const agents = []; ast.children.forEach((child) => { if (child.type === 'Agent') { agents.push(child.syntax); } }); this.config.syntax = agents.length > 0 ? agents : [AdblockSyntax.Common]; } } return 0; })(); // Clear next line stuff if the line was processed with code 0 if (code === 0) { nextLineDisabled.clear(); nextLineEnabled.clear(); isDisabledForNextLine = false; isEnabledForNextLine = false; } }); // Invoke onEndFilterList event after parsing the filter list invokeEvent('onEndFilterList'); // Build fixed content if fixing is enabled if (fix) { const fixedFilterList = { type: 'FilterList', children: [], }; // Iterate over all rules in the original filter list for (let i = 0; i < filterList.children.length; i += 1) { const rule = filterList.children[i]; // Find the fix for the current rule const fixed = []; // Currently only 1 fix is allowed per rule let matches = 0; for (const problem of result.problems) { if (problem.fix && problem.position.startLine === i + 1 && i + 1 === problem.position.endLine) { const fixes = Array.isArray(problem.fix) ? problem.fix : [problem.fix]; // We prefer to use raw generated content if available, so // we can avoid generating wrong (old) content again, if // the rule was changed in the meantime fixed.push(...(fixes.map((e) => { if (e.raws && e.raws.text) { delete e.raws.text; } return e; }))); matches += 1; } } // Push the fixed version of the rule to the fixed filter list if // matches is 1 (only 1 fix is allowed per rule), otherwise push // the original rule if (matches === 1) { fixedFilterList.children.push(...fixed); } else { fixedFilterList.children.push(rule); } } result.fixed = FilterListGenerator.generate(fixedFilterList, true); } // Return linting result return result; } } export { Linter };