using Sprache; using Core.Entities; using Core.Models; namespace Core.Parsers; /// /// Grammar definitions for parsing event occurrence DSL using parser combinators. /// Provides composable parsers for each grammar rule. /// public static class EventOccurrenceGrammar { /// /// Array of all month names in order (January through December). /// This is the single source of truth for month names used throughout the parser. /// public static readonly string[] MonthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; // Build month parsers dynamically from MonthNames array private static readonly Parser[] MonthParsers = MonthNames .Select(month => Parse.String(month).Text().Token()) .ToArray(); /// /// Parser for month names (January through December). /// Built dynamically from MonthNames array. /// public static readonly Parser Month = MonthParsers .Aggregate((current, next) => current.Or(next)); /// /// Parser for day of month (1-31, optional semicolon). /// public static readonly Parser DayOfMonth = from day in Parse.Number from semicolon in Parse.Char(';').Optional() select int.Parse(day); // Time parsing components private static readonly Parser Noon = Parse.String("NOON").Text().Token(); private static readonly Parser Tbd = Parse.String("TBD").Text().Token(); private static readonly Parser AmPm = Parse.String("a.m.").Or(Parse.String("am")).Or(Parse.String("A.M.")).Or(Parse.String("AM")) .Or(Parse.String("p.m.")).Or(Parse.String("pm")).Or(Parse.String("P.M.")).Or(Parse.String("PM")) .Text().Token(); private static readonly Parser TimeValue = from hour in Parse.Number from colon in Parse.Char(':').Optional() from minute in Parse.Number.Optional() from ws in Parse.WhiteSpace.Many() from ampm in AmPm select $"{hour}:{(minute.IsDefined ? minute.Get() : "00")} {ampm}"; /// /// Parser for hyphen character. /// Note: Input is assumed to be normalized (en-dash and em-dash converted to regular hyphen) via SanitizeInput. /// public static readonly Parser Hyphen = Parse.Char('-'); /// /// Parser for time values, including ranges and special values (NOON, TBD). /// public static readonly Parser Time = Noon.Or(Tbd) .Or( from start in TimeValue.Or(Noon) from dash in Hyphen.Then(_ => Parse.WhiteSpace.Many()).Optional() from end in TimeValue.Or(Noon).Optional() select end.IsDefined ? $"{start} - {end.Get()}" : start ); /// /// Parser for section headers: EventName - (MS|HS). /// Note: Input is assumed to be normalized (hyphens normalized) via SanitizeInput. /// public static readonly Parser<(string EventName, string SchoolLevel)> SectionHeader = from eventName in Parse.AnyChar.Except(Hyphen).Many().Text().Token() from hyphen in Hyphen.Token() from schoolLevel in Parse.String("MS").Or(Parse.String("HS")).Text().Token() select (eventName.Trim(), schoolLevel); /// /// Parser for General Schedule/Session headers. /// public static readonly Parser GeneralSchedule = Parse.String("General Schedule").Or(Parse.String("General Session")).Text().Token(); /// /// Parser for comment lines (starting with #). /// public static readonly Parser CommentLine = from hash in Parse.Char('#') from rest in Parse.AnyChar.Many().Text() select rest; /// /// Attempts to parse a section header from the given line. /// Returns null if not a section header. /// public static (string EventName, string SchoolLevel)? TryParseSectionHeader(string line) { try { var result = SectionHeader.Parse(line); return result; } catch (Sprache.ParseException) { // Expected - line is not a section header, return null return null; } catch { // Unexpected exception, return null to continue parsing return null; } } /// /// Attempts to parse a General Schedule/Session header from the given line. /// Returns null if not a General Schedule header. /// public static bool IsGeneralSchedule(string line) { try { GeneralSchedule.Parse(line); return true; } catch (Sprache.ParseException) { // Expected - line is not a General Schedule header return false; } catch { // Unexpected exception, return false to continue parsing return false; } } /// /// Attempts to parse a comment line from the given line. /// Returns true if the line is a comment. /// public static bool IsCommentLine(string line) { return line.TrimStart().StartsWith("#", StringComparison.Ordinal); } /// /// Attempts to parse an occurrence line from the given text. /// Returns null if parsing fails. /// Strategy: Find the first month name in the line, then parse from there. /// public static (string Name, string Month, int Day, string TimeAndLocation)? TryParseOccurrenceLine(string line) { // Find the first occurrence of any month name (using normalized MonthNames array) int monthIndex = -1; string foundMonth = string.Empty; foreach (var month in MonthNames) { var index = line.IndexOf(month, StringComparison.OrdinalIgnoreCase); if (index >= 0 && (monthIndex < 0 || index < monthIndex)) { monthIndex = index; foundMonth = month; } } if (monthIndex < 0) return null; // Extract name (everything before the month) var name = line.Substring(0, monthIndex).Trim(); // Parse from the month onwards var restOfLine = line.Substring(monthIndex); try { var monthParser = Parse.String(foundMonth).Text().Token(); var result = from month in monthParser from day in DayOfMonth.Token() from timeAndLocation in Parse.AnyChar.Many().Text() select (name, month, day, timeAndLocation.Trim()); var parsed = result.Parse(restOfLine); return parsed; } catch (Sprache.ParseException) { // Expected - line is not a valid occurrence line, return null return null; } catch { // Unexpected exception, return null to continue parsing return null; } } }