From ba82b9206a8b99ae50d7ba027446d014ad025842 Mon Sep 17 00:00:00 2001 From: windyboy Date: Wed, 24 Dec 2025 17:40:52 +0800 Subject: [PATCH] refactor aviation parser architecture --- internal/adapter/parser/aviation/aviation.go | 89 ++------- internal/adapter/parser/aviation/pattern.go | 53 +---- internal/adapter/parser/aviation/registry.go | 188 ++++++++++++++++++ internal/adapter/parser/aviation/tokenizer.go | 61 ++++++ .../adapter/parser/aviation/tokenizer_test.go | 128 ++++++++++++ 5 files changed, 395 insertions(+), 124 deletions(-) create mode 100644 internal/adapter/parser/aviation/registry.go create mode 100644 internal/adapter/parser/aviation/tokenizer.go create mode 100644 internal/adapter/parser/aviation/tokenizer_test.go diff --git a/internal/adapter/parser/aviation/aviation.go b/internal/adapter/parser/aviation/aviation.go index 7a1a0dd..24e7630 100644 --- a/internal/adapter/parser/aviation/aviation.go +++ b/internal/adapter/parser/aviation/aviation.go @@ -2,7 +2,6 @@ package aviation import ( "caatsm/internal/adapter/dto" - "caatsm/internal/domain" "errors" "fmt" "regexp" @@ -68,13 +67,26 @@ func (parser *BodyParser) Parse() (string, interface{}, error) { return "", nil, fmt.Errorf("no category found in body text") } - if patternConfig, exists := parser.bodyPatterns[category]; exists && patternConfig.Patterns != nil { - for _, p := range patternConfig.Patterns { - if data := extract(parser.body, p.Expression); data != nil { - return parser.createBodyData(data) + patternConfig, exists := parser.bodyPatterns[category] + if !exists || patternConfig.Patterns == nil { + return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body) + } + + ctx := ParseContext{ + Body: parser.body, + Tokens: Tokenizer{}.Tokenize(parser.body), + } + + for _, p := range patternConfig.Patterns { + if data := extract(parser.body, p.Expression); data != nil { + parsed, err := parseCategory(category, ctx, data) + if err != nil { + return "", nil, err } + return category, parsed, nil } } + return "", nil, fmt.Errorf("no matching pattern found for body: %s", parser.body) } @@ -107,73 +119,6 @@ func extractData(match []string, re *regexp.Regexp) map[string]string { return data } -func (parser *BodyParser) createBodyData(data map[string]string) (string, interface{}, error) { - switch category := data["category"]; category { - case CategoryArrival: - return category, &domain.ARR{ - Category: data[Category], - AircraftID: data[FlightNumber], - SSRModeAndCode: data[SSR], - DepartureAirport: data[DepartureCode], - ArrivalAirport: data[ArrivalCode], - ArrivalTime: data[ArrivalTime], - }, nil - case CategoryDeparture: - return category, &domain.DEP{ - Category: data[Category], - AircraftID: data[FlightNumber], - SSRModeAndCode: data[SSR], - DepartureAirport: data[DepartureCode], - DepartureTime: data[DepartureTime], - Destination: data[ArrivalCode], - }, nil - case CategoryCancellation: - return category, &domain.CNL{ - Category: data[Category], - AircraftID: data[FlightNumber], - DepartureAirport: data[DepartureCode], - DestinationAirport: data[ArrivalCode], - }, nil - case CategoryDelay: - return category, &domain.DLA{ - Category: data[Category], - AircraftID: data[FlightNumber], - DepartureAirport: data[DepartureCode], - NewDepartureTime: data[DepartureTime], - ArrivalAirport: data[ArrivalCode], - ArrivalTime: data[ArrivalTime], - }, nil - case CategoryFlightPlan: - otherData := parseOther(data[OtherInfo]) - return category, &domain.FPL{ - Category: data[Category], - FlightNumber: data[FlightNumber], - ReferenceData: data[ReferenceData], - AircraftID: data[AircraftID], - SSRModeAndCode: data[Surveillance], - FlightRulesAndType: data[Indicator], - CruisingSpeedAndLevel: data[Speed] + data[Level], - DepartureAirport: data[DepartureCode], - DepartureTime: data[DepartureTime], - Route: data[Route], - DestinationAndTotalTime: data[DestinationCode] + data[EstimatedTime], - AlternateAirport: data[AlternateAirport], - OtherInfo: data[OtherInfo], - Register: otherData[Register], - EstimatedArrivalTime: data[EstimatedTime], - PBN: otherData[PBN], - NavigationEquipment: otherData[NavigationEquipment], - EstimatedElapsedTime: otherData[EstimatedElapsedTime], - SELCALCode: otherData[SELCALCode], - PerformanceCategory: otherData[PerformanceCategory], - RerouteInformation: otherData[RerouteInformation], - Remarks: otherData[Remarks], - }, nil - default: - return category, nil, fmt.Errorf("invalid message type: %s", category) - } -} - func headerToParsedTelegram(header Header) dto.ParsedTelegram { return dto.ParsedTelegram{ MessageID: header.MessageID, diff --git a/internal/adapter/parser/aviation/pattern.go b/internal/adapter/parser/aviation/pattern.go index bc4f1eb..41b11f1 100644 --- a/internal/adapter/parser/aviation/pattern.go +++ b/internal/adapter/parser/aviation/pattern.go @@ -15,60 +15,9 @@ type PatternConfig struct { } var ( - bodyPatterns = map[string]BodyConfig{} + bodyPatterns = buildBodyPatterns() ) -func init() { - // Initialize body patterns. - bodyPatterns = map[string]BodyConfig{ - "ARR": { - Patterns: []PatternConfig{ - { - Pattern: ArrPatternString, - Comments: "Pattern for ARR message", - Expression: ArrPatternExpression, - }, - }, - }, - "DEP": { - Patterns: []PatternConfig{ - { - Pattern: DepPatternString, - Comments: "Pattern for DEP message", - Expression: DepPatternExpression, - }, - }, - }, - "FPL": { - Patterns: []PatternConfig{ - { - Pattern: FplPatternString, - Comments: "Pattern for FPL message", - Expression: FplPatternExpression, - }, - }, - }, - "CNL": { - Patterns: []PatternConfig{ - { - Pattern: CnlPatternString, - Comments: "Pattern for CNL message", - Expression: CnlPatternExpression, - }, - }, - }, - "DLA": { - Patterns: []PatternConfig{ - { - Pattern: DlaPatternString, - Comments: "Pattern for DLA message", - Expression: DlaPatternExpression, - }, - }, - }, - } -} - // FindPatterns finds the matching body configuration based on the message body. func FindPatterns(messageBody string) *BodyConfig { if match := BodyTypePattern.FindStringSubmatch(messageBody); len(match) > 1 { diff --git a/internal/adapter/parser/aviation/registry.go b/internal/adapter/parser/aviation/registry.go new file mode 100644 index 0000000..1236555 --- /dev/null +++ b/internal/adapter/parser/aviation/registry.go @@ -0,0 +1,188 @@ +package aviation + +import ( + "caatsm/internal/domain" + "fmt" +) + +// ParseContext carries the raw body and tokens for field parsers. +type ParseContext struct { + Body string + Tokens []Token +} + +// CategoryParser maps a category to patterns and output parsing. +type CategoryParser interface { + Category() string + Patterns() []PatternConfig + Parse(ctx ParseContext, data map[string]string) (interface{}, error) +} + +type arrParser struct{} + +func (arrParser) Category() string { return CategoryArrival } + +func (arrParser) Patterns() []PatternConfig { + return []PatternConfig{ + { + Pattern: ArrPatternString, + Comments: "Pattern for ARR message", + Expression: ArrPatternExpression, + }, + } +} + +func (arrParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) { + return &domain.ARR{ + Category: data[Category], + AircraftID: data[FlightNumber], + SSRModeAndCode: data[SSR], + DepartureAirport: data[DepartureCode], + ArrivalAirport: data[ArrivalCode], + ArrivalTime: data[ArrivalTime], + }, nil +} + +type depParser struct{} + +func (depParser) Category() string { return CategoryDeparture } + +func (depParser) Patterns() []PatternConfig { + return []PatternConfig{ + { + Pattern: DepPatternString, + Comments: "Pattern for DEP message", + Expression: DepPatternExpression, + }, + } +} + +func (depParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) { + return &domain.DEP{ + Category: data[Category], + AircraftID: data[FlightNumber], + SSRModeAndCode: data[SSR], + DepartureAirport: data[DepartureCode], + DepartureTime: data[DepartureTime], + Destination: data[ArrivalCode], + }, nil +} + +type cnlParser struct{} + +func (cnlParser) Category() string { return CategoryCancellation } + +func (cnlParser) Patterns() []PatternConfig { + return []PatternConfig{ + { + Pattern: CnlPatternString, + Comments: "Pattern for CNL message", + Expression: CnlPatternExpression, + }, + } +} + +func (cnlParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) { + return &domain.CNL{ + Category: data[Category], + AircraftID: data[FlightNumber], + DepartureAirport: data[DepartureCode], + DestinationAirport: data[ArrivalCode], + }, nil +} + +type dlaParser struct{} + +func (dlaParser) Category() string { return CategoryDelay } + +func (dlaParser) Patterns() []PatternConfig { + return []PatternConfig{ + { + Pattern: DlaPatternString, + Comments: "Pattern for DLA message", + Expression: DlaPatternExpression, + }, + } +} + +func (dlaParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) { + return &domain.DLA{ + Category: data[Category], + AircraftID: data[FlightNumber], + DepartureAirport: data[DepartureCode], + NewDepartureTime: data[DepartureTime], + ArrivalAirport: data[ArrivalCode], + ArrivalTime: data[ArrivalTime], + }, nil +} + +type fplParser struct{} + +func (fplParser) Category() string { return CategoryFlightPlan } + +func (fplParser) Patterns() []PatternConfig { + return []PatternConfig{ + { + Pattern: FplPatternString, + Comments: "Pattern for FPL message", + Expression: FplPatternExpression, + }, + } +} + +func (fplParser) Parse(_ ParseContext, data map[string]string) (interface{}, error) { + otherData := parseOther(data[OtherInfo]) + return &domain.FPL{ + Category: data[Category], + FlightNumber: data[FlightNumber], + ReferenceData: data[ReferenceData], + AircraftID: data[AircraftID], + SSRModeAndCode: data[Surveillance], + FlightRulesAndType: data[Indicator], + CruisingSpeedAndLevel: data[Speed] + data[Level], + DepartureAirport: data[DepartureCode], + DepartureTime: data[DepartureTime], + Route: data[Route], + DestinationAndTotalTime: data[DestinationCode] + data[EstimatedTime], + AlternateAirport: data[AlternateAirport], + OtherInfo: data[OtherInfo], + Register: otherData[Register], + EstimatedArrivalTime: data[EstimatedTime], + PBN: otherData[PBN], + NavigationEquipment: otherData[NavigationEquipment], + EstimatedElapsedTime: otherData[EstimatedElapsedTime], + SELCALCode: otherData[SELCALCode], + PerformanceCategory: otherData[PerformanceCategory], + RerouteInformation: otherData[RerouteInformation], + Remarks: otherData[Remarks], + }, nil +} + +var categoryRegistry = map[string]CategoryParser{ + CategoryArrival: arrParser{}, + CategoryDeparture: depParser{}, + CategoryCancellation: cnlParser{}, + CategoryDelay: dlaParser{}, + CategoryFlightPlan: fplParser{}, +} + +func lookupCategoryParser(category string) (CategoryParser, bool) { + parser, ok := categoryRegistry[category] + return parser, ok +} + +func buildBodyPatterns() map[string]BodyConfig { + patterns := make(map[string]BodyConfig, len(categoryRegistry)) + for category, parser := range categoryRegistry { + patterns[category] = BodyConfig{Patterns: parser.Patterns()} + } + return patterns +} + +func parseCategory(category string, ctx ParseContext, data map[string]string) (interface{}, error) { + parser, ok := lookupCategoryParser(category) + if !ok { + return nil, fmt.Errorf("invalid message type: %s", category) + } + return parser.Parse(ctx, data) +} diff --git a/internal/adapter/parser/aviation/tokenizer.go b/internal/adapter/parser/aviation/tokenizer.go new file mode 100644 index 0000000..8438ff0 --- /dev/null +++ b/internal/adapter/parser/aviation/tokenizer.go @@ -0,0 +1,61 @@ +package aviation + +import "strings" + +// Token represents a lexeme in the body with its byte offsets. +type Token struct { + Text string + Start int + End int +} + +// Tokenizer splits text into tokens using a whitespace set. +// A forward slash is treated as whitespace but is emitted as its own token. +type Tokenizer struct { + Whitespace string +} + +// Tokenize tokenizes input and returns tokens with byte offsets. +func (t Tokenizer) Tokenize(input string) []Token { + if t.Whitespace == "" { + t.Whitespace = " \n\t\r" + } + + var tokens []Token + start := -1 + + for idx, r := range input { + if strings.ContainsRune(t.Whitespace, r) { + if start != -1 { + tokens = append(tokens, Token{ + Text: input[start:idx], + Start: start, + End: idx, + }) + start = -1 + } + if r == '/' { + tokens = append(tokens, Token{ + Text: "/", + Start: idx, + End: idx + 1, + }) + } + continue + } + + if start == -1 { + start = idx + } + } + + if start != -1 { + tokens = append(tokens, Token{ + Text: input[start:], + Start: start, + End: len(input), + }) + } + + return tokens +} diff --git a/internal/adapter/parser/aviation/tokenizer_test.go b/internal/adapter/parser/aviation/tokenizer_test.go new file mode 100644 index 0000000..c856ce4 --- /dev/null +++ b/internal/adapter/parser/aviation/tokenizer_test.go @@ -0,0 +1,128 @@ +package aviation + +import ( + "caatsm/internal/domain" + "testing" +) + +func TestTokenizerDefaultWhitespace(t *testing.T) { + t.Parallel() + + input := "A B\nC\tD\rE" + tokens := Tokenizer{}.Tokenize(input) + + expected := []Token{ + {Text: "A", Start: 0, End: 1}, + {Text: "B", Start: 2, End: 3}, + {Text: "C", Start: 4, End: 5}, + {Text: "D", Start: 6, End: 7}, + {Text: "E", Start: 8, End: 9}, + } + + if len(tokens) != len(expected) { + t.Fatalf("expected %d tokens, got %d", len(expected), len(tokens)) + } + + for i, token := range tokens { + if token != expected[i] { + t.Fatalf("token %d mismatch: got %#v, expected %#v", i, token, expected[i]) + } + } +} + +func TestTokenizerSlashWhitespace(t *testing.T) { + t.Parallel() + + input := "A/B C" + tokens := Tokenizer{Whitespace: " \n\t\r/"}.Tokenize(input) + + expected := []Token{ + {Text: "A", Start: 0, End: 1}, + {Text: "/", Start: 1, End: 2}, + {Text: "B", Start: 2, End: 3}, + {Text: "C", Start: 4, End: 5}, + } + + if len(tokens) != len(expected) { + t.Fatalf("expected %d tokens, got %d", len(expected), len(tokens)) + } + + for i, token := range tokens { + if token != expected[i] { + t.Fatalf("token %d mismatch: got %#v, expected %#v", i, token, expected[i]) + } + } +} + +func TestBuildBodyPatternsIncludesCategories(t *testing.T) { + t.Parallel() + + patterns := buildBodyPatterns() + for _, category := range []string{ + CategoryArrival, + CategoryDeparture, + CategoryCancellation, + CategoryDelay, + CategoryFlightPlan, + } { + config, ok := patterns[category] + if !ok { + t.Fatalf("expected category %s in body patterns", category) + } + if len(config.Patterns) == 0 || config.Patterns[0].Expression == nil { + t.Fatalf("expected pattern expression for category %s", category) + } + } +} + +func TestParseCategoryInvalid(t *testing.T) { + t.Parallel() + + _, err := parseCategory("XYZ", ParseContext{}, map[string]string{}) + if err == nil { + t.Fatal("expected error for invalid category") + } +} + +func TestParseCategoryFPL(t *testing.T) { + t.Parallel() + + body := `(FPL-CCA1532-IS +-A332/H +-SDE3FGHIJ4J5M1RWY/LB101 +-ZSSS2035 +-K0859S1040 PIAKS G330 PIMOL A539 BTO W82 DOGAR +-ZBAA0153 ZBYN +-PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED)` + + data := extract(body, FplPatternExpression) + if data == nil { + t.Fatal("expected FPL pattern to match") + } + + parsed, err := parseCategory(CategoryFlightPlan, ParseContext{ + Body: body, + Tokens: Tokenizer{}.Tokenize(body), + }, data) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + fpl, ok := parsed.(*domain.FPL) + if !ok { + t.Fatalf("expected *domain.FPL, got %T", parsed) + } + + if fpl.FlightNumber != "CCA1532" { + t.Fatalf("expected flight number CCA1532, got %s", fpl.FlightNumber) + } + if fpl.PBN != "A1B2B3B4B5D1L1" { + t.Fatalf("expected PBN A1B2B3B4B5D1L1, got %s", fpl.PBN) + } + if fpl.RerouteInformation != "FRT N640 ZBYN" { + t.Fatalf("expected reroute information, got %s", fpl.RerouteInformation) + } + if fpl.Remarks != "TCAS EQUIPPED" { + t.Fatalf("expected remarks TCAS EQUIPPED, got %s", fpl.Remarks) + } +}