diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 71bbce6f3..f382314f5 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -1,9 +1,7 @@ import * as React from "react"; -import * as CompileConfig from "../../../config.json"; import Config from "../../config"; -import { AdvancedSkipRuleSet, SkipRuleAttribute, SkipRuleOperator } from "../../utils/skipRule"; -import { ActionType, ActionTypes, CategorySkipOption } from "../../types"; +import { AdvancedSkipRule, configToText, parseConfig, } from "../../utils/skipRule"; let configSaveTimeout: NodeJS.Timeout | null = null; @@ -64,210 +62,16 @@ export function AdvancedSkipOptionsComponent() { ); } -function compileConfig(config: string): AdvancedSkipRuleSet[] | null { - const ruleSets: AdvancedSkipRuleSet[] = []; +function compileConfig(config: string): AdvancedSkipRule[] | null { + const { rules, errors } = parseConfig(config); - let ruleSet: AdvancedSkipRuleSet = { - rules: [], - skipOption: null, - comment: "" - }; - - for (const line of config.split("\n")) { - if (line.trim().length === 0) { - // Skip empty lines - continue; - } - - const comment = line.match(/^\s*\/\/(.+)$/); - if (comment) { - if (ruleSet.rules.length > 0) { - // Rule has already been created, add it to list if valid - if (ruleSet.skipOption !== null && ruleSet.rules.length > 0) { - ruleSets.push(ruleSet); - - ruleSet = { - rules: [], - skipOption: null, - comment: "" - }; - } else { - return null; - } - } - - if (ruleSet.comment.length > 0) { - ruleSet.comment += "; "; - } - - ruleSet.comment += comment[1].trim(); - - // Skip comment lines - continue; - } else if (line.startsWith("if ")) { - if (ruleSet.rules.length > 0) { - // Rule has already been created, add it to list if valid - if (ruleSet.skipOption !== null && ruleSet.rules.length > 0) { - ruleSets.push(ruleSet); - - ruleSet = { - rules: [], - skipOption: null, - comment: "" - }; - } else { - return null; - } - } - - const ruleTexts = [...line.matchAll(/\S+ \S+ (?:"[^"\\]*(?:\\.[^"\\]*)*"|\d+)(?= and |$)/g)]; - for (const ruleText of ruleTexts) { - if (!ruleText[0]) return null; - - const ruleParts = ruleText[0].match(/(\S+) (\S+) ("[^"\\]*(?:\\.[^"\\]*)*"|\d+)/); - if (ruleParts.length !== 4) { - return null; // Invalid rule format - } - - const attribute = getSkipRuleAttribute(ruleParts[1]); - const operator = getSkipRuleOperator(ruleParts[2]); - const value = getSkipRuleValue(ruleParts[3]); - if (attribute === null || operator === null || value === null) { - return null; // Invalid attribute or operator - } - - if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { - if (attribute === SkipRuleAttribute.Category - && !CompileConfig.categoryList.includes(value as string)) { - return null; // Invalid category value - } else if (attribute === SkipRuleAttribute.ActionType - && !ActionTypes.includes(value as ActionType)) { - return null; // Invalid category value - } else if (attribute === SkipRuleAttribute.Source - && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { - return null; // Invalid category value - } - } - - ruleSet.rules.push({ - attribute, - operator, - value - }); - } - - // Make sure all rules were parsed - if (ruleTexts.length === 0 || !line.endsWith(ruleTexts[ruleTexts.length - 1][0])) { - return null; - } - } else { - // Only continue if a rule has been defined - if (ruleSet.rules.length === 0) { - return null; // No rules defined yet - } - - switch (line.trim().toLowerCase()) { - case "disabled": - ruleSet.skipOption = CategorySkipOption.Disabled; - break; - case "show overlay": - ruleSet.skipOption = CategorySkipOption.ShowOverlay; - break; - case "manual skip": - ruleSet.skipOption = CategorySkipOption.ManualSkip; - break; - case "auto skip": - ruleSet.skipOption = CategorySkipOption.AutoSkip; - break; - default: - return null; // Invalid skip option - } - } + for (const error of errors) { + console.error(`[SB] Error on line ${error.span.start.line}: ${error.message}`); } - if (ruleSet.rules.length > 0 && ruleSet.skipOption !== null) { - ruleSets.push(ruleSet); - } else if (ruleSet.rules.length > 0 || ruleSet.skipOption !== null) { - // Incomplete rule set - return null; - } - - return ruleSets; -} - -function getSkipRuleAttribute(attribute: string): SkipRuleAttribute | null { - if (attribute && Object.values(SkipRuleAttribute).includes(attribute as SkipRuleAttribute)) { - return attribute as SkipRuleAttribute; - } - - return null; -} - -function getSkipRuleOperator(operator: string): SkipRuleOperator | null { - if (operator && Object.values(SkipRuleOperator).includes(operator as SkipRuleOperator)) { - return operator as SkipRuleOperator; - } - - return null; -} - -function getSkipRuleValue(value: string): string | number | null { - if (!value) return null; - - if (value.startsWith('"')) { - try { - return JSON.parse(value); - } catch (e) { - return null; // Invalid JSON string - } + if (errors.length === 0) { + return rules; } else { - const numValue = Number(value); - if (!isNaN(numValue)) { - return numValue; - } - return null; } } - -function configToText(config: AdvancedSkipRuleSet[]): string { - let result = ""; - - for (const ruleSet of config) { - if (ruleSet.comment) { - result += "// " + ruleSet.comment + "\n"; - } - - result += "if "; - let firstRule = true; - for (const rule of ruleSet.rules) { - if (!firstRule) { - result += " and "; - } - - result += `${rule.attribute} ${rule.operator} ${JSON.stringify(rule.value)}`; - firstRule = false; - } - - switch (ruleSet.skipOption) { - case CategorySkipOption.Disabled: - result += "\nDisabled"; - break; - case CategorySkipOption.ShowOverlay: - result += "\nShow Overlay"; - break; - case CategorySkipOption.ManualSkip: - result += "\nManual Skip"; - break; - case CategorySkipOption.AutoSkip: - result += "\nAuto Skip"; - break; - default: - return null; // Invalid skip option - } - - result += "\n\n"; - } - - return result.trim(); -} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 27eba2ecd..3e0078178 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,9 @@ import * as CompileConfig from "../config.json"; import * as invidiousList from "../ci/invidiouslist.json"; -import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType, SegmentListDefaultTab } from "./types"; -import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config"; +import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorHideType, SponsorTime, VideoID, SegmentListDefaultTab } from "./types"; +import { Keybind, keybindEquals, ProtoConfig } from "../maze-utils/src/config"; import { HashedValue } from "../maze-utils/src/hash"; -import { Permission, AdvancedSkipRuleSet } from "./utils/skipRule"; +import { AdvancedSkipCheck, AdvancedSkipPredicate, AdvancedSkipRule, Permission, PredicateOperator } from "./utils/skipRule"; interface SBConfig { userID: string; @@ -156,7 +156,7 @@ interface SBStorage { /* VideoID prefixes to UUID prefixes */ downvotedSegments: Record; navigationApiAvailable: boolean; - + // Used when sync storage disabled alreadyInstalled: boolean; @@ -167,7 +167,7 @@ interface SBStorage { skipProfileTemp: { time: number; configID: ConfigurationID } | null; skipProfiles: Record; - skipRules: AdvancedSkipRuleSet[]; + skipRules: AdvancedSkipRule[]; } class ConfigClass extends ProtoConfig { @@ -187,6 +187,43 @@ class ConfigClass extends ProtoConfig { } function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { + if (local["skipRules"] && local["skipRules"].length !== 0 && local["skipRules"][0]["rules"]) { + const output: AdvancedSkipRule[] = []; + + for (const rule of local["skipRules"]) { + const rules: object[] = rule["rules"]; + + if (rules.length !== 0) { + let predicate: AdvancedSkipPredicate = { + kind: "check", + ...rules[0] as AdvancedSkipCheck, + }; + + for (let i = 1; i < rules.length; i++) { + predicate = { + kind: "operator", + operator: PredicateOperator.And, + left: predicate, + right: { + kind: "check", + ...rules[i] as AdvancedSkipCheck, + }, + }; + } + + const comment = rule["comment"] as string; + + output.push({ + predicate, + skipOption: rule.skipOption, + comments: comment.length === 0 ? [] : comment.split(/;\s*/), + }); + } + } + + local["skipRules"] = output; + } + if (config["whitelistedChannels"]) { // convert to skipProfiles const whitelistedChannels = config["whitelistedChannels"] as string[]; @@ -213,7 +250,7 @@ function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { for (const channelID of whitelistedChannels) { local.channelSkipProfileIDs[channelID] = skipProfileID; } - local.channelSkipProfileIDs = local.channelSkipProfileIDs; + local.channelSkipProfileIDs = local.channelSkipProfileIDs; chrome.storage.sync.remove("whitelistedChannels"); } @@ -247,7 +284,7 @@ function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { name: "chapter" as Category, option: CategorySkipOption.ShowOverlay }); - + config.categorySelections = config.categorySelections; } } diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 17497b504..b94961ddb 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -1,21 +1,25 @@ import { getCurrentPageTitle } from "../../maze-utils/src/elements"; import { getChannelIDInfo, getVideoDuration } from "../../maze-utils/src/video"; import Config from "../config"; -import { CategorySelection, CategorySkipOption, SponsorSourceType, SponsorTime } from "../types"; +import {ActionType, ActionTypes, CategorySelection, CategorySkipOption, SponsorSourceType, SponsorTime} from "../types"; import { getSkipProfile, getSkipProfileBool } from "./skipProfiles"; import { VideoLabelsCacheData } from "./videoLabels"; +import * as CompileConfig from "../../config.json"; export interface Permission { canSubmit: boolean; } +// Note that attributes that are prefixes of other attributes (like `time.start`) need to be ordered *after* +// the longer attributes, because these are matched sequentially. Using the longer attribute would otherwise result +// in an error token. export enum SkipRuleAttribute { - StartTime = "time.start", - EndTime = "time.end", - Duration = "time.duration", StartTimePercent = "time.startPercent", + StartTime = "time.start", EndTimePercent = "time.endPercent", + EndTime = "time.end", DurationPercent = "time.durationPercent", + Duration = "time.duration", Category = "category", ActionType = "actionType", Description = "chapter.name", @@ -26,38 +30,77 @@ export enum SkipRuleAttribute { Title = "video.title" } +// Note that operators that are prefixes of other attributes (like `<`) need to be ordered *after* the longer +// operators, because these are matched sequentially. Using the longer operator would otherwise result +// in an error token. export enum SkipRuleOperator { - Less = "<", LessOrEqual = "<=", - Greater = ">", + Less = "<", GreaterOrEqual = ">=", - Equal = "==", + Greater = ">", NotEqual = "!=", - Contains = "*=", + Equal = "==", NotContains = "!*=", - Regex = "~=", - RegexIgnoreCase = "~i=", + Contains = "*=", NotRegex = "!~=", - NotRegexIgnoreCase = "!~i=" + Regex = "~=", + NotRegexIgnoreCase = "!~i=", + RegexIgnoreCase = "~i=" } -export interface AdvancedSkipRule { +const SKIP_RULE_ATTRIBUTES = Object.values(SkipRuleAttribute); +const SKIP_RULE_OPERATORS = Object.values(SkipRuleOperator); +const INVERTED_SKIP_RULE_OPERATORS = { + "<=": SkipRuleOperator.Greater, + "<": SkipRuleOperator.GreaterOrEqual, + ">=": SkipRuleOperator.Less, + ">": SkipRuleOperator.LessOrEqual, + "!=": SkipRuleOperator.Equal, + "==": SkipRuleOperator.NotEqual, + "!*=": SkipRuleOperator.Contains, + "*=": SkipRuleOperator.NotContains, + "!~=": SkipRuleOperator.Regex, + "~=": SkipRuleOperator.NotRegex, + "!~i=": SkipRuleOperator.RegexIgnoreCase, + "~i=": SkipRuleOperator.NotRegexIgnoreCase, +}; +const WORD_EXTRA_CHARACTER = /[a-zA-Z0-9.]/; +const OPERATOR_EXTRA_CHARACTER = /[<>=!~*&|-]/; +const ANY_EXTRA_CHARACTER = /[a-zA-Z0-9<>=!~*&|.-]/; + +export interface AdvancedSkipCheck { + kind: "check"; attribute: SkipRuleAttribute; operator: SkipRuleOperator; value: string | number; } -export interface AdvancedSkipRuleSet { - rules: AdvancedSkipRule[]; +export enum PredicateOperator { + And = "and", + Or = "or", +} + +export interface AdvancedSkipOperator { + kind: "operator"; + operator: PredicateOperator; + left: AdvancedSkipPredicate; + right: AdvancedSkipPredicate; + displayInverted?: boolean; +} + +export type AdvancedSkipPredicate = AdvancedSkipCheck | AdvancedSkipOperator; + +export interface AdvancedSkipRule { + predicate: AdvancedSkipPredicate; skipOption: CategorySkipOption; - comment: string; + comments: string[]; } export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection { // First check skip rules - for (const ruleSet of Config.local.skipRules) { - if (ruleSet.rules.every((rule) => isSkipRulePassing(segment, rule))) { - return { name: segment.category, option: ruleSet.skipOption } as CategorySelection; + for (const rule of Config.local.skipRules) { + if (isSkipPredicatePassing(segment, rule.predicate)) { + return { name: segment.category, option: rule.skipOption } as CategorySelection; } } @@ -84,7 +127,7 @@ export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData return { name: segment.category, option: CategorySkipOption.Disabled} as CategorySelection; } -function getSkipRuleValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipRule): string | number | undefined { +function getSkipCheckValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): string | number | undefined { switch (rule.attribute) { case SkipRuleAttribute.StartTime: return (segment as SponsorTime).segment?.[0]; @@ -143,9 +186,9 @@ function getSkipRuleValue(segment: SponsorTime | VideoLabelsCacheData, rule: Adv } } -function isSkipRulePassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipRule): boolean { - const value = getSkipRuleValue(segment, rule); - +function isSkipCheckPassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): boolean { + const value = getSkipCheckValue(segment, rule); + switch (rule.operator) { case SkipRuleOperator.Less: return typeof value === "number" && value < (rule.value as number); @@ -176,6 +219,19 @@ function isSkipRulePassing(segment: SponsorTime | VideoLabelsCacheData, rule: Ad } } +function isSkipPredicatePassing(segment: SponsorTime | VideoLabelsCacheData, predicate: AdvancedSkipPredicate): boolean { + if (predicate.kind === "check") { + return isSkipCheckPassing(segment, predicate as AdvancedSkipCheck); + } else { // predicate.kind === "operator" + // TODO Is recursion fine to use here? + if (predicate.operator == PredicateOperator.And) { + return isSkipPredicatePassing(segment, predicate.left) && isSkipPredicatePassing(segment, predicate.right); + } else { // predicate.operator === PredicateOperator.Or + return isSkipPredicatePassing(segment, predicate.left) || isSkipPredicatePassing(segment, predicate.right); + } + } +} + export function getCategoryDefaultSelection(category: string): CategorySelection { for (const selection of Config.config.categorySelections) { if (selection.name === category) { @@ -183,4 +239,762 @@ export function getCategoryDefaultSelection(category: string): CategorySelection } } return { name: category, option: CategorySkipOption.Disabled} as CategorySelection; -} \ No newline at end of file +} + +type TokenType = + | "if" // Keywords + | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option + | `${SkipRuleAttribute}` // Segment attributes + | `${SkipRuleOperator}` // Segment attribute operators + | "and" | "or" | "not" // Expression operators + | "(" | ")" | "comment" // Syntax + | "string" | "number" // Literal values + | "eof" | "error"; // Sentinel and special tokens + +export interface SourcePos { + line: number; +} + +export interface Span { + start: SourcePos; + end: SourcePos; +} + +interface Token { + type: TokenType; + span: Span; + value: string; +} + +class Lexer { + private readonly source: string; + private start: number; + private current: number; + + private start_pos: SourcePos; + private current_pos: SourcePos; + + public constructor(source: string) { + this.source = source; + this.start = 0; + this.current = 0; + this.start_pos = { line: 1 }; + this.current_pos = { line: 1 }; + } + + private makeToken(type: TokenType): Token { + return { + type, + span: { start: this.start_pos, end: this.current_pos, }, + value: this.source.slice(this.start, this.current), + }; + } + + /** + * Returns the UTF-16 value at the current position and advances it forward. + * If the end of the source string has been reached, returns null. + * + * @return current UTF-16 value, or null on EOF + */ + private consume(): string | null { + if (this.source.length > this.current) { + // The UTF-16 value at the current position, which could be either a Unicode code point or a lone surrogate. + // The check above this is also based on the UTF-16 value count, so this should not be able to fail on “weird” inputs. + const c = this.source[this.current]; + this.current++; + + if (c === "\n") { + // Cannot use this.current_pos.line++, because SourcePos is mutable and used in tokens without copying + this.current_pos = { line: this.current_pos.line + 1, }; + } + + return c; + } else { + return null; + } + } + + /** + * Returns the UTF-16 value at the current position without advancing it. + * If the end of the source string has been reached, returns null. + * + * @return current UTF-16 value, or null on EOF + */ + private peek(): string | null { + if (this.source.length > this.current) { + // See comment in consume() for Unicode expectations here + return this.source[this.current]; + } else { + return null; + } + } + + /** + * Checks the word at the current position against a list of + * expected keywords. The keyword can consist of multiple characters. + * If a match is found, the current position is advanced by the length + * of the keyword found. + * + * @param keywords the expected set of keywords at the current position + * @param caseSensitive whether to do a case-sensitive comparison + * @return the matching keyword, or null + */ + private expectKeyword(keywords: readonly string[], caseSensitive: boolean): string | null { + for (const keyword of keywords) { + // slice() clamps to string length, so cannot cause out of bounds errors + const actual = this.source.slice(this.current, this.current + keyword.length); + + if (caseSensitive && keyword === actual || !caseSensitive && keyword.toLowerCase() === actual.toLowerCase()) { + // Does not handle keywords containing line feeds, which shouldn't happen anyway + this.current += keyword.length; + return keyword; + } + } + + return null; + } + + /** + * Skips a series of whitespace characters starting at the current + * position. May advance the current position multiple times, once, + * or not at all. + */ + private skipWhitespace() { + let c = this.peek(); + const whitespace = /\s+/; + + while (c != null) { + if (!whitespace.test(c)) { + return; + } + + this.consume(); + c = this.peek(); + } + } + + /** + * Skips all characters until the next "\n" (line feed) + * character occurs (inclusive). Will always advance the current position + * at least once. + */ + private skipLine() { + let c = this.consume(); + while (c != null) { + if (c == '\n') { + return; + } + + c = this.consume(); + } + } + + /** + * @return whether the lexer has reached the end of input + */ + private isEof(): boolean { + return this.current >= this.source.length; + } + + /** + * Sets the start position of the next token that will be emitted + * to the current position. + * + * More characters need to be consumed after calling this, as + * an empty token would be emitted otherwise. + */ + private resetToCurrent() { + this.start = this.current; + this.start_pos = this.current_pos; + } + + public nextToken(): Token { + this.skipWhitespace(); + this.resetToCurrent(); + + if (this.isEof()) { + return this.makeToken("eof"); + } + + const keyword = this.expectKeyword([ + "if", "and", "or", "not", + "(", ")", + "//", + ].concat(SKIP_RULE_ATTRIBUTES) + .concat(SKIP_RULE_OPERATORS), true); + let type: TokenType | null = null; + let kind: "word" | "operator" | null = null; + + if (keyword !== null) { + if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword)) { + kind = "word"; + type = keyword as TokenType; + } else if ((SKIP_RULE_OPERATORS as string[]).includes(keyword)) { + kind = "operator"; + type = keyword as TokenType; + } else { + switch (keyword) { + case "if": // Fallthrough + case "and": // Fallthrough + case "or": // Fallthrough + case "not": kind = "word"; type = keyword as TokenType; break; + + case "(": return this.makeToken("("); + case ")": return this.makeToken(")"); + + case "//": + this.resetToCurrent(); + this.skipLine(); + return this.makeToken("comment"); + + default: + } + } + } else { + const keyword2 = this.expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); + + if (keyword2 !== null) { + kind = "word"; + type = keyword2 as TokenType; + } + } + + if (type !== null) { + const more = kind == "operator" ? OPERATOR_EXTRA_CHARACTER : kind == "word" ? WORD_EXTRA_CHARACTER : ANY_EXTRA_CHARACTER; + + let c = this.peek(); + let error = false; + while (c !== null && more.test(c)) { + error = true; + this.consume(); + c = this.peek(); + } + + return this.makeToken(error ? "error" : type); + } + + let c = this.consume(); + + if (c === '"') { + // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” + let output = ""; + let c = this.consume(); + let error = false; + + while (c !== null && c !== '"') { + if (c == '\\') { + c = this.consume(); + + switch (c) { + case '"': + output = output.concat('"'); + break; + case '\\': + output = output.concat('\\'); + break; + case '/': + output = output.concat('/'); + break; + case 'b': + output = output.concat('\b'); + break; + case 'f': + output = output.concat('\f'); + break; + case 'n': + output = output.concat('\n'); + break; + case 'r': + output = output.concat('\r'); + break; + case 't': + output = output.concat('\t'); + break; + case 'u': { + // UTF-16 value sequence + const digits = this.source.slice(this.current, this.current + 4); + + if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { + error = true; + output = output.concat(`\\u`); + c = this.consume(); + continue; + } + + const value = parseInt(digits, 16); + // fromCharCode() takes a UTF-16 value without performing validity checks, + // which is exactly what is needed here – in JSON, code units outside the + // BMP are represented by two Unicode escape sequences. + output = output.concat(String.fromCharCode(value)); + break; + } + default: + error = true; + output = output.concat(`\\${c}`); + break; + } + } else if (c === '\n') { + // Unterminated / multi-line string, unsupported + error = true; + // Prevent unterminated strings from consuming the entire rest of the input + break; + } else { + output = output.concat(c); + } + + c = this.consume(); + } + + return { + type: error || c !== '"' ? "error" : "string", + span: { start: this.start_pos, end: this.current_pos, }, + value: output, + }; + } else if (/[0-9-]/.test(c)) { + // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” + if (c === '-') { + c = this.consume(); + + if (!/[0-9]/.test(c)) { + return this.makeToken("error"); + } + } + + const leadingZero = c === '0'; + let next = this.peek(); + let error = false; + + while (next !== null && /[0-9]/.test(next)) { + this.consume(); + next = this.peek(); + + if (leadingZero) { + error = true; + } + } + + + if (next !== null && next === '.') { + this.consume(); + next = this.peek(); + + if (next === null || !/[0-9]/.test(next)) { + return this.makeToken("error"); + } + + do { + this.consume(); + next = this.peek(); + } while (next !== null && /[0-9]/.test(next)); + } + + next = this.peek(); + + if (next != null && (next === 'e' || next === 'E')) { + this.consume(); + next = this.peek(); + + if (next === null) { + return this.makeToken("error"); + } + + if (next === '+' || next === '-') { + this.consume(); + next = this.peek(); + } + + while (next !== null && /[0-9]/.test(next)) { + this.consume(); + next = this.peek(); + } + } + + return this.makeToken(error ? "error" : "number"); + } + + // Consume common characters up to a space for a more useful value in the error token + const common = ANY_EXTRA_CHARACTER; + c = this.peek(); + while (c !== null && common.test(c)) { + this.consume(); + c = this.peek(); + } + + return this.makeToken("error"); + } +} + +export interface ParseError { + span: Span; + message: string; +} + +class Parser { + private lexer: Lexer; + + private previous: Token; + private current: Token; + + private readonly rules: AdvancedSkipRule[]; + private readonly errors: ParseError[]; + + private erroring: boolean; + private panicMode: boolean; + + public constructor(lexer: Lexer) { + this.lexer = lexer; + this.previous = null; + this.current = lexer.nextToken(); + this.rules = []; + this.errors = []; + this.erroring = false; + this.panicMode = false; + } + + // Helper functions + + /** + * Adds an error message. The current skip rule will be marked as erroring. + * + * @param span the range of the error + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ + private errorAt(span: Span, message: string, panic: boolean) { + if (!this.panicMode) { + this.errors.push({span, message,}); + } + + this.panicMode ||= panic; + this.erroring = true; + } + + /** + * Adds an error message for an error occurring at the previous token + * (which was just consumed). + * + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ + private error(message: string, panic: boolean) { + this.errorAt(this.previous.span, message, panic); + } + + /** + * Adds an error message for an error occurring at the current token + * (which has not been consumed yet). + * + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ + private errorAtCurrent(message: string, panic: boolean) { + this.errorAt(this.current.span, message, panic); + } + + /** + * Consumes the current token, which can then be accessed at previous. + * The next token will be at current after this call. + * + * If a token of type error is found, issues an error message. + */ + private consume() { + this.previous = this.current; + // Intentionally ignoring `error` tokens here; + // by handling those in later privates with more context (match(), expect(), ...), + // the user gets better errors + this.current = this.lexer.nextToken(); + } + + /** + * Checks the current token (that has not been consumed yet) against a set of expected token types. + * + * @param expected the set of expected token types + * @return whether the actual current token matches any expected token type + */ + private match(expected: readonly TokenType[]): boolean { + if (expected.includes(this.current.type)) { + this.consume(); + return true; + } else { + return false; + } + } + + /** + * Checks the current token (that has not been consumed yet) against a set of expected token types. + * + * If there is no match, issues an error message which will be prepended to , got: . + * + * @param expected the set of expected token types + * @param message the error message to report in case the actual token doesn't match + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ + private expect(expected: readonly TokenType[], message: string, panic: boolean) { + if (!this.match(expected)) { + this.errorAtCurrent(message.concat(this.current.type === "error" ? `, got: ${JSON.stringify(this.current.value)}` : `, got: \`${this.current.type}\``), panic); + } + } + + /** + * Synchronize with the next rule block and disable panic mode. + * Skips all tokens until the if keyword is found. + */ + private synchronize() { + this.panicMode = false; + + while (!this.isEof()) { + if (this.current.type === "if") { + return; + } + + this.consume(); + } + } + + /** + * @return whether the parser has reached the end of input + */ + private isEof(): boolean { + return this.current.type === "eof"; + } + + // Parsing functions + + /** + * Parse the config. Should only ever be called once on a given + * Parser instance. + */ + public parse(): { rules: AdvancedSkipRule[]; errors: ParseError[] } { + while (!this.isEof()) { + this.erroring = false; + const rule = this.parseRule(); + + if (!this.erroring && rule) { + this.rules.push(rule); + } + + if (this.panicMode) { + this.synchronize(); + } + } + + return { rules: this.rules, errors: this.errors, }; + } + + private parseRule(): AdvancedSkipRule | null { + const rule: AdvancedSkipRule = { + predicate: null, + skipOption: null, + comments: [], + }; + + while (this.match(["comment"])) { + rule.comments.push(this.previous.value.trim()); + } + + this.expect(["if"], rule.comments.length !== 0 ? "expected `if` after `comment`" : "expected `if`", true); + rule.predicate = this.parsePredicate(); + + this.expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after condition", true); + switch (this.previous.type) { + case "disabled": + rule.skipOption = CategorySkipOption.Disabled; + break; + case "show overlay": + rule.skipOption = CategorySkipOption.ShowOverlay; + break; + case "manual skip": + rule.skipOption = CategorySkipOption.ManualSkip; + break; + case "auto skip": + rule.skipOption = CategorySkipOption.AutoSkip; + break; + default: + // Ignore, should have already errored + } + + return rule; + } + + private parsePredicate(): AdvancedSkipPredicate | null { + return this.parseOr(); + } + + private parseOr(): AdvancedSkipPredicate | null { + let left = this.parseAnd(); + + while (this.match(["or"])) { + const right = this.parseAnd(); + + left = { + kind: "operator", + operator: PredicateOperator.Or, + left, right, + }; + } + + return left; + } + + private parseAnd(): AdvancedSkipPredicate | null { + let left = this.parseUnary(); + + while (this.match(["and"])) { + const right = this.parseUnary(); + + left = { + kind: "operator", + operator: PredicateOperator.And, + left, right, + }; + } + + return left; + } + + private parseUnary(): AdvancedSkipPredicate | null { + if (this.match(["not"])) { + const predicate = this.parseUnary(); + return predicate ? invertPredicate(predicate) : null; + } + + return this.parsePrimary(); + } + + private parsePrimary(): AdvancedSkipPredicate | null { + if (this.match(["("])) { + const predicate = this.parsePredicate(); + this.expect([")"], "expected `)` after condition", true); + return predicate; + } else { + return this.parseCheck(); + } + } + + private parseCheck(): AdvancedSkipCheck | null { + this.expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${this.previous.type}\``, true); + + if (this.erroring) { + return null; + } + + const attribute = this.previous.type as SkipRuleAttribute; + this.expect(SKIP_RULE_OPERATORS, `expected operator after \`${attribute}\``, true); + + if (this.erroring) { + return null; + } + + const operator = this.previous.type as SkipRuleOperator; + this.expect(["string", "number"], `expected string or number after \`${operator}\``, true); + + if (this.erroring) { + return null; + } + + const value = this.previous.type === "number" ? Number(this.previous.value) : this.previous.value; + + if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { + if (attribute === SkipRuleAttribute.Category + && !CompileConfig.categoryList.includes(value as string)) { + this.error(`unknown category: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.ActionType + && !ActionTypes.includes(value as ActionType)) { + this.error(`unknown action type: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.Source + && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { + this.error(`unknown chapter source: \`${value}\``, false); + return null; + } + } + + return { + kind: "check", + attribute, operator, value, + }; + } +} + +export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } { + const parser = new Parser(new Lexer(config)); + return parser.parse(); +} + +export function configToText(config: AdvancedSkipRule[]): string { + let result = ""; + + for (const rule of config) { + for (const comment of rule.comments) { + result += "// " + comment + "\n"; + } + + result += "if "; + result += predicateToText(rule.predicate, null); + + switch (rule.skipOption) { + case CategorySkipOption.Disabled: + result += "\nDisabled"; + break; + case CategorySkipOption.ShowOverlay: + result += "\nShow Overlay"; + break; + case CategorySkipOption.ManualSkip: + result += "\nManual Skip"; + break; + case CategorySkipOption.AutoSkip: + result += "\nAuto Skip"; + break; + default: + return null; // Invalid skip option + } + + result += "\n\n"; + } + + return result.trim(); +} + +function predicateToText(predicate: AdvancedSkipPredicate, outerPrecedence: "or" | "and" | "not" | null): string { + if (predicate.kind === "check") { + return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; + } else if (predicate.displayInverted) { + // Should always be fine, considering `not` has the highest precedence + return `not ${predicateToText(invertPredicate(predicate), "not")}`; + } else { + let text: string; + + if (predicate.operator === PredicateOperator.And) { + text = `${predicateToText(predicate.left, "and")} and ${predicateToText(predicate.right, "and")}`; + } else { // Or + text = `${predicateToText(predicate.left, "or")} or ${predicateToText(predicate.right, "or")}`; + } + + return outerPrecedence !== null && outerPrecedence !== predicate.operator ? `(${text})` : text; + } +} + +function invertPredicate(predicate: AdvancedSkipPredicate): AdvancedSkipPredicate { + if (predicate.kind === "check") { + return { + ...predicate, + operator: INVERTED_SKIP_RULE_OPERATORS[predicate.operator], + }; + } else { + // not (a and b) == (not a or not b) + // not (a or b) == (not a and not b) + return { + kind: "operator", + operator: predicate.operator === "and" ? PredicateOperator.Or : PredicateOperator.And, + left: predicate.left ? invertPredicate(predicate.left) : null, + right: predicate.right ? invertPredicate(predicate.right) : null, + displayInverted: !predicate.displayInverted, + }; + } +}