From 2a2d9de817eaafc513698f73e5d7e45147d7d864 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:03:33 +0200 Subject: [PATCH] Implement parser and configToText, remove old parser --- .../options/AdvancedSkipOptionsComponent.tsx | 211 +----- src/config.ts | 10 +- src/utils/skipRule.ts | 641 ++++++++++++------ 3 files changed, 471 insertions(+), 391 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 2f7665bd..89b382a7 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -1,9 +1,8 @@ import * as React from "react"; -import * as CompileConfig from "../../../config.json"; import Config from "../../config"; -import {AdvancedSkipRuleSet, compileConfigNew, SkipRuleAttribute, SkipRuleOperator} from "../../utils/skipRule"; -import { ActionType, ActionTypes, CategorySkipOption } from "../../types"; +import {AdvancedSkipPredicate, AdvancedSkipRule, parseConfig, PredicateOperator,} from "../../utils/skipRule"; +import {CategorySkipOption} from "../../types"; let configSaveTimeout: NodeJS.Timeout | null = null; @@ -64,206 +63,43 @@ export function AdvancedSkipOptionsComponent() { ); } -function compileConfig(config: string): AdvancedSkipRuleSet[] | null { - // Debug - compileConfigNew(config); +function compileConfig(config: string): AdvancedSkipRule[] | null { + const { rules, errors } = parseConfig(config); - const ruleSets: AdvancedSkipRuleSet[] = []; - - 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.log(`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 { +function configToText(config: AdvancedSkipRule[]): string { let result = ""; - for (const ruleSet of config) { - if (ruleSet.comment) { - result += "// " + ruleSet.comment + "\n"; + for (const rule of config) { + for (const comment of rule.comments) { + result += "// " + comment + "\n"; } result += "if "; - let firstRule = true; - for (const rule of ruleSet.rules) { - if (!firstRule) { - result += " and "; - } + result += predicateToText(rule.predicate, PredicateOperator.Or); - result += `${rule.attribute} ${rule.operator} ${JSON.stringify(rule.value)}`; - firstRule = false; - } - - switch (ruleSet.skipOption) { + switch (rule.skipOption) { case CategorySkipOption.Disabled: result += "\nDisabled"; break; case CategorySkipOption.ShowOverlay: - result += "\nShow Overlay"; + result += "\nShow overlay"; break; case CategorySkipOption.ManualSkip: - result += "\nManual Skip"; + result += "\nManual skip"; break; case CategorySkipOption.AutoSkip: - result += "\nAuto Skip"; + result += "\nAuto skip"; break; default: return null; // Invalid skip option @@ -274,3 +110,16 @@ function configToText(config: AdvancedSkipRuleSet[]): string { return result.trim(); } + +function predicateToText(predicate: AdvancedSkipPredicate, highestPrecedence: PredicateOperator): string { + if (predicate.kind === "check") { + return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; + } else { + if (predicate.operator === PredicateOperator.And) { + return `${predicateToText(predicate.left, PredicateOperator.And)} and ${predicateToText(predicate.right, PredicateOperator.And)}`; + } else { // Or + const text = `${predicateToText(predicate.left, PredicateOperator.Or)} or ${predicateToText(predicate.right, PredicateOperator.Or)}`; + return highestPrecedence == PredicateOperator.And ? `(${text})` : text; + } + } +} diff --git a/src/config.ts b/src/config.ts index 4261f66f..73f16048 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import * as invidiousList from "../ci/invidiouslist.json"; import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType } from "./types"; import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config"; import { HashedValue } from "../maze-utils/src/hash"; -import { Permission, AdvancedSkipRuleSet } from "./utils/skipRule"; +import { Permission, AdvancedSkipRule } from "./utils/skipRule"; interface SBConfig { userID: string; @@ -155,7 +155,7 @@ interface SBStorage { /* VideoID prefixes to UUID prefixes */ downvotedSegments: Record; navigationApiAvailable: boolean; - + // Used when sync storage disabled alreadyInstalled: boolean; @@ -166,7 +166,7 @@ interface SBStorage { skipProfileTemp: { time: number; configID: ConfigurationID } | null; skipProfiles: Record; - skipRules: AdvancedSkipRuleSet[]; + skipRules: AdvancedSkipRule[]; } class ConfigClass extends ProtoConfig { @@ -212,7 +212,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"); } @@ -246,7 +246,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 5f2b35b9..91a03014 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -1,9 +1,10 @@ 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; @@ -41,23 +42,38 @@ export enum SkipRuleOperator { NotRegexIgnoreCase = "!~i=" } -export interface AdvancedSkipRule { +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; +} + +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 +100,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,8 +159,8 @@ 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: @@ -176,6 +192,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) { @@ -188,19 +217,19 @@ export function getCategoryDefaultSelection(category: string): CategorySelection type TokenType = | "if" // Keywords | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option - | keyof typeof SkipRuleAttribute // Segment attributes - | keyof typeof SkipRuleOperator // Segment attribute operators + | `${SkipRuleAttribute}` // Segment attributes + | `${SkipRuleOperator}` // Segment attribute operators | "and" | "or" // Expression operators - | "(" | ")" // Syntax + | "(" | ")" | "comment" // Syntax | "string" | "number" // Literal values | "eof" | "error"; // Sentinel and special tokens -interface SourcePos { +export interface SourcePos { line: number; // column: number; } -interface Span { +export interface Span { start: SourcePos; end: SourcePos; } @@ -341,218 +370,222 @@ function nextToken(state: LexerState): Token { state.start_pos = state.current_pos; } - for (;;) { - skipWhitespace(); - resetToCurrent(); + skipWhitespace(); + resetToCurrent(); - if (isEof()) { - return makeToken("eof"); + if (isEof()) { + return makeToken("eof"); + } + + const keyword = expectKeyword([ + "if", "and", "or", + "(", ")", + "//", + ].concat(Object.values(SkipRuleAttribute)) + .concat(Object.values(SkipRuleOperator)), true); + + if (keyword !== null) { + switch (keyword) { + case "if": return makeToken("if"); + case "and": return makeToken("and"); + case "or": return makeToken("or"); + + case "(": return makeToken("("); + case ")": return makeToken(")"); + + case "time.start": return makeToken("time.start"); + case "time.end": return makeToken("time.end"); + case "time.duration": return makeToken("time.duration"); + case "time.startPercent": return makeToken("time.startPercent"); + case "time.endPercent": return makeToken("time.endPercent"); + case "time.durationPercent": return makeToken("time.durationPercent"); + case "category": return makeToken("category"); + case "actionType": return makeToken("actionType"); + case "chapter.name": return makeToken("chapter.name"); + case "chapter.source": return makeToken("chapter.source"); + case "channel.id": return makeToken("channel.id"); + case "channel.name": return makeToken("channel.name"); + case "video.duration": return makeToken("video.duration"); + case "video.title": return makeToken("video.title"); + + case "<": return makeToken("<"); + case "<=": return makeToken("<="); + case ">": return makeToken(">"); + case ">=": return makeToken(">="); + case "==": return makeToken("=="); + case "!=": return makeToken("!="); + case "*=": return makeToken("*="); + case "!*=": return makeToken("!*="); + case "~=": return makeToken("~="); + case "~i=": return makeToken("~i="); + case "!~=": return makeToken("!~="); + case "!~i=": return makeToken("!~i="); + + case "//": + resetToCurrent(); + skipLine(); + return makeToken("comment"); + + default: } + } - const keyword = expectKeyword([ - "if", "and", "or", - "(", ")", - "//", - ].concat(Object.values(SkipRuleAttribute)) - .concat(Object.values(SkipRuleOperator)), true); + const keyword2 = expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - if (keyword !== null) { - switch (keyword) { - case "if": return makeToken("if"); - case "and": return makeToken("and"); - case "or": return makeToken("or"); - - case "(": return makeToken("("); - case ")": return makeToken(")"); - - case "time.start": return makeToken("StartTime"); - case "time.end": return makeToken("EndTime"); - case "time.duration": return makeToken("Duration"); - case "time.startPercent": return makeToken("StartTimePercent"); - case "time.endPercent": return makeToken("EndTimePercent"); - case "time.durationPercent": return makeToken("DurationPercent"); - case "category": return makeToken("Category"); - case "actionType": return makeToken("ActionType"); - case "chapter.name": return makeToken("Description"); - case "chapter.source": return makeToken("Source"); - case "channel.id": return makeToken("ChannelID"); - case "channel.name": return makeToken("ChannelName"); - case "video.duration": return makeToken("VideoDuration"); - case "video.title": return makeToken("Title"); - - case "<": return makeToken("Less"); - case "<=": return makeToken("LessOrEqual"); - case ">": return makeToken("Greater"); - case ">=": return makeToken("GreaterOrEqual"); - case "==": return makeToken("Equal"); - case "!=": return makeToken("NotEqual"); - case "*=": return makeToken("Contains"); - case "!*=": return makeToken("NotContains"); - case "~=": return makeToken("Regex"); - case "~i=": return makeToken("RegexIgnoreCase"); - case "!~=": return makeToken("NotRegex"); - case "!~i=": return makeToken("NotRegexIgnoreCase"); - - case "//": - skipLine(); - continue; - - default: - } + if (keyword2 !== null) { + switch (keyword2) { + case "disabled": return makeToken("disabled"); + case "show overlay": return makeToken("show overlay"); + case "manual skip": return makeToken("manual skip"); + case "auto skip": return makeToken("auto skip"); + default: } + } - const keyword2 = expectKeyword( - [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - - if (keyword2 !== null) { - switch (keyword2) { - case "disabled": return makeToken("disabled"); - case "show overlay": return makeToken("show overlay"); - case "manual skip": return makeToken("manual skip"); - case "auto skip": return makeToken("auto skip"); - default: - } - } + let c = consume(); + if (c === '"') { + // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” + let output = ""; let c = consume(); + let error = false; - if (c === '"') { - // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” - let output = ""; - let c = consume(); - let error = false; + while (c !== null && c !== '"') { + if (c == '\\') { + c = consume(); - while (c !== null && c !== '"') { - if (c == '\\') { - c = 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 = state.source.slice(state.current, state.current + 4); - 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 = state.source.slice(state.current, state.current + 4); - - if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { - error = true; - output = output.concat(`\\u`); - c = 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: + if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { error = true; - output = output.concat(`\\${c}`); - break; + output = output.concat(`\\u`); + c = 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; } - } else { - output = output.concat(c); + default: + error = true; + output = output.concat(`\\${c}`); + break; } - - c = consume(); + } else { + output = output.concat(c); } - return { - type: error || c !== '"' ? "error" : "string", - span: { start: state.start_pos, end: state.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 = consume(); + c = consume(); + } - if (!/[0-9]/.test(c)) { - return makeToken("error"); - } + return { + type: error || c !== '"' ? "error" : "string", + span: { start: state.start_pos, end: state.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 = consume(); + + if (!/[0-9]/.test(c)) { + return makeToken("error"); + } + } + + const leadingZero = c === '0'; + let next = peek(); + let error = false; + + while (next !== null && /[0-9]/.test(next)) { + consume(); + next = peek(); + + if (leadingZero) { + error = true; + } + } + + + if (next !== null && next === '.') { + consume(); + next = peek(); + + if (next === null || !/[0-9]/.test(next)) { + return makeToken("error"); } - const leadingZero = c === '0'; - let next = peek(); - let error = false; + do { + consume(); + next = peek(); + } while (next !== null && /[0-9]/.test(next)); + } + + next = peek(); + + if (next != null && (next === 'e' || next === 'E')) { + consume(); + next = peek(); + + if (next === null) { + return makeToken("error"); + } + + if (next === '+' || next === '-') { + consume(); + next = peek(); + } while (next !== null && /[0-9]/.test(next)) { consume(); next = peek(); - - if (leadingZero) { - error = true; - } } - - - if (next !== null && next === '.') { - consume(); - next = peek(); - - if (next === null || !/[0-9]/.test(next)) { - return makeToken("error"); - } - - do { - consume(); - next = peek(); - } while (next !== null && /[0-9]/.test(next)); - } - - next = peek(); - - if (next != null && (next === 'e' || next === 'E')) { - consume(); - next = peek(); - - if (next === null) { - return makeToken("error"); - } - - if (next === '+' || next === '-') { - consume(); - next = peek(); - } - - while (next !== null && /[0-9]/.test(next)) { - consume(); - next = peek(); - } - } - - return makeToken(error ? "error" : "number"); } - return makeToken("error"); + return makeToken(error ? "error" : "number"); } + + return makeToken("error"); } -export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null { +export interface ParseError { + span: Span; + message: string; +} + +export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } { // Mutated by calls to nextToken() const lexerState: LexerState = { source: config, @@ -563,14 +596,212 @@ export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null { current_pos: { line: 1 }, }; - let token = nextToken(lexerState); + let previous: Token = null; + let current: Token = nextToken(lexerState); - while (token.type !== "eof") { - console.log(token); + const rules: AdvancedSkipRule[] = []; + const errors: ParseError[] = []; + let erroring = false; + let panicMode = false; - token = nextToken(lexerState); + function errorAt(span: Span, message: string, panic: boolean) { + if (!panicMode) { + errors.push({span, message,}); + } + + panicMode ||= panic; + erroring = true; } - // TODO - return null; + function error(message: string, panic: boolean) { + errorAt(previous.span, message, panic); + } + + function errorAtCurrent(message: string, panic: boolean) { + errorAt(current.span, message, panic); + } + + function consume() { + previous = current; + current = nextToken(lexerState); + + while (current.type === "error") { + errorAtCurrent(`Unexpected token: ${JSON.stringify(current)}`, true); + current = nextToken(lexerState); + } + } + + function match(expected: readonly TokenType[]): boolean { + if (expected.includes(current.type)) { + consume(); + return true; + } else { + return false; + } + } + + function expect(expected: readonly TokenType[], message: string, panic: boolean) { + if (!match(expected)) { + errorAtCurrent(message.concat(`, got: \`${current.type}\``), panic); + } + } + + function synchronize() { + panicMode = false; + + while (!isEof()) { + if (current.type === "if") { + return; + } + + consume(); + } + } + + function isEof(): boolean { + return current.type === "eof"; + } + + while (!isEof()) { + erroring = false; + const rule = parseRule(); + + if (!erroring) { + rules.push(rule); + } + + if (panicMode) { + synchronize(); + } + } + + return { rules, errors, }; + + function parseRule(): AdvancedSkipRule { + const rule: AdvancedSkipRule = { + predicate: null, + skipOption: null, + comments: [], + }; + + while (match(["comment"])) { + rule.comments.push(previous.value.trim()); + } + + expect(["if"], "Expected `if`", true); + + rule.predicate = parsePredicate(); + + expect(["disabled", "show overlay", "manual skip", "auto skip"], "Expected skip option after predicate", true); + + switch (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; + } + + function parsePredicate(): AdvancedSkipPredicate { + return parseOr(); + } + + function parseOr(): AdvancedSkipPredicate { + let left = parseAnd(); + + while (match(["or"])) { + const right = parseAnd(); + + left = { + kind: "operator", + operator: PredicateOperator.Or, + left, right, + }; + } + + return left; + } + + function parseAnd(): AdvancedSkipPredicate { + let left = parsePrimary(); + + while (match(["and"])) { + const right = parsePrimary(); + + left = { + kind: "operator", + operator: PredicateOperator.And, + left, right, + }; + } + + return left; + } + + function parsePrimary(): AdvancedSkipPredicate { + if (match(["("])) { + const predicate = parsePredicate(); + expect([")"], "Expected `)` after predicate", true); + return predicate; + } else { + return parseCheck(); + } + } + + function parseCheck(): AdvancedSkipCheck { + expect(Object.values(SkipRuleAttribute), "Expected attribute", true); + + if (erroring) { + return null; + } + + const attribute = previous.type as SkipRuleAttribute; + expect(Object.values(SkipRuleOperator), "Expected operator after attribute", true); + + if (erroring) { + return null; + } + + const operator = previous.type as SkipRuleOperator; + expect(["string", "number"], "Expected string or number after operator", true); + + if (erroring) { + return null; + } + + const value = previous.type === "number" ? Number(previous.value) : previous.value; + + if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { + if (attribute === SkipRuleAttribute.Category + && !CompileConfig.categoryList.includes(value as string)) { + error(`Unknown category: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.ActionType + && !ActionTypes.includes(value as ActionType)) { + error(`Unknown action type: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.Source + && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { + error(`Unknown chapter source: \`${value}\``, false); + return null; + } + } + + return { + kind: "check", + attribute, operator, value, + }; + } }