Files
chapter-organizer/Core/Parsers/EventOccurrenceGrammar.cs
T
poprhythm c4e7edf3db Improve error handling in EventOccurrenceGrammar parsing methods
This commit enhances the error handling in the EventOccurrenceGrammar class by adding specific catch blocks for expected parse exceptions. Each parsing method now gracefully returns null or false when encountering a known parse exception, while still handling unexpected exceptions to ensure the parsing process continues smoothly. This change improves the robustness and clarity of the parsing logic.
2026-01-09 10:50:18 -05:00

212 lines
7.3 KiB
C#

using Sprache;
using Core.Entities;
using Core.Models;
namespace Core.Parsers;
/// <summary>
/// Grammar definitions for parsing event occurrence DSL using parser combinators.
/// Provides composable parsers for each grammar rule.
/// </summary>
public static class EventOccurrenceGrammar
{
/// <summary>
/// Array of all month names in order (January through December).
/// This is the single source of truth for month names used throughout the parser.
/// </summary>
public static readonly string[] MonthNames = new[]
{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
};
// Build month parsers dynamically from MonthNames array
private static readonly Parser<string>[] MonthParsers = MonthNames
.Select(month => Parse.String(month).Text().Token())
.ToArray();
/// <summary>
/// Parser for month names (January through December).
/// Built dynamically from MonthNames array.
/// </summary>
public static readonly Parser<string> Month = MonthParsers
.Aggregate((current, next) => current.Or(next));
/// <summary>
/// Parser for day of month (1-31, optional semicolon).
/// </summary>
public static readonly Parser<int> DayOfMonth =
from day in Parse.Number
from semicolon in Parse.Char(';').Optional()
select int.Parse(day);
// Time parsing components
private static readonly Parser<string> Noon = Parse.String("NOON").Text().Token();
private static readonly Parser<string> Tbd = Parse.String("TBD").Text().Token();
private static readonly Parser<string> 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<string> 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}";
/// <summary>
/// Parser for hyphen character.
/// Note: Input is assumed to be normalized (en-dash and em-dash converted to regular hyphen) via SanitizeInput.
/// </summary>
public static readonly Parser<char> Hyphen = Parse.Char('-');
/// <summary>
/// Parser for time values, including ranges and special values (NOON, TBD).
/// </summary>
public static readonly Parser<string> 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
);
/// <summary>
/// Parser for section headers: EventName - (MS|HS).
/// Note: Input is assumed to be normalized (hyphens normalized) via SanitizeInput.
/// </summary>
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);
/// <summary>
/// Parser for General Schedule/Session headers.
/// </summary>
public static readonly Parser<string> GeneralSchedule =
Parse.String("General Schedule").Or(Parse.String("General Session")).Text().Token();
/// <summary>
/// Parser for comment lines (starting with #).
/// </summary>
public static readonly Parser<string> CommentLine =
from hash in Parse.Char('#')
from rest in Parse.AnyChar.Many().Text()
select rest;
/// <summary>
/// Attempts to parse a section header from the given line.
/// Returns null if not a section header.
/// </summary>
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;
}
}
/// <summary>
/// Attempts to parse a General Schedule/Session header from the given line.
/// Returns null if not a General Schedule header.
/// </summary>
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;
}
}
/// <summary>
/// Attempts to parse a comment line from the given line.
/// Returns true if the line is a comment.
/// </summary>
public static bool IsCommentLine(string line)
{
return line.TrimStart().StartsWith("#", StringComparison.Ordinal);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}