///|
fn empty_span() -> Span {
Span::new(0, 0)
}
///|
fn error_at(
token : Token,
code : DiagnosticCode,
message : String,
) -> Diagnostic {
Diagnostic::new(Error, code, message, token.span, token=token.lexeme)
}
///|
fn warning_at(
token : Token,
code : DiagnosticCode,
message : String,
) -> Diagnostic {
Diagnostic::new(Warning, code, message, token.span, token=token.lexeme)
}
///|
fn parse_observation_time_token(
token : Token,
) -> Result[ObservationTime, Diagnostic] {
let text = token.lexeme
if text.length() != 7 || !text.has_suffix("Z") || !is_digits(text.sub(end=6)) {
return Err(
error_at(token, InvalidTime, "observation time must use DDHHMMZ"),
)
}
let day = parse_unsigned(text.sub(end=2)).unwrap()
let hour = parse_unsigned(text.sub(start=2, end=4)).unwrap()
let minute = parse_unsigned(text.sub(start=4, end=6)).unwrap()
if day < 1 || day > 31 || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
Err(error_at(token, InvalidTime, "observation time is out of range"))
} else {
Ok({ day, hour, minute })
}
}
///|
fn strip_wind_unit(text : String) -> (StringView, WindUnit)? {
match text.strip_suffix("MPS") {
Some(body) => Some((body, MetersPerSecond))
None =>
match text.strip_suffix("KT") {
Some(body) => Some((body, Knots))
None => None
}
}
}
///|
fn parse_wind_token(token : Token) -> Result[Wind, Diagnostic] {
let text = token.lexeme
match strip_wind_unit(text) {
None =>
Err(error_at(token, InvalidWind, "wind group must end with KT or MPS"))
Some((body, unit)) => {
let (direction, variable, rest) = if body.has_prefix("VRB") {
(None, true, body.sub(start=3))
} else if body.length() >= 5 && is_digits(body.sub(end=3)) {
let dir = parse_unsigned(body.sub(end=3)).unwrap()
if dir > 360 {
return Err(
error_at(
token,
InvalidWind,
"wind direction must be 000..360 or VRB",
),
)
}
(Some(dir), false, body.sub(start=3))
} else {
return Err(
error_at(token, InvalidWind, "wind direction must be 000..360 or VRB"),
)
}
let (speed_text, gust_text) = match rest.split_once("G") {
Some((speed, gust)) => (speed, Some(gust))
None => (rest, None)
}
if !is_digits(speed_text) {
return Err(error_at(token, InvalidWind, "wind speed must be digits"))
}
let speed = parse_unsigned(speed_text).unwrap()
let gust = match gust_text {
Some(gust_digits) => {
if !is_digits(gust_digits) {
return Err(error_at(token, InvalidWind, "wind gust must be digits"))
}
Some(parse_unsigned(gust_digits).unwrap())
}
None => None
}
Ok({
direction_degrees: direction,
variable_direction: variable,
speed,
gust,
unit,
})
}
}
}
///|
fn looks_like_wind(text : String) -> Bool {
text.has_suffix("KT") || text.has_suffix("MPS")
}
///|
fn parse_variable_wind_token(token : Token) -> VariableWindDirection? {
let text = token.lexeme
if text.length() == 7 &&
text.sub(start=3, end=4).equal("V") &&
is_digits(text.sub(end=3)) &&
is_digits(text.sub(start=4)) {
let from_degrees = parse_unsigned(text.sub(end=3)).unwrap()
let to_degrees = parse_unsigned(text.sub(start=4)).unwrap()
if from_degrees <= 360 && to_degrees <= 360 {
Some({ from_degrees, to_degrees })
} else {
None
}
} else {
None
}
}
///|
fn parse_visibility_token(token : Token) -> Visibility? {
let text = token.lexeme
if text == "CAVOK" {
Some({ value: 10000, unit: Meters, greater_than: true })
} else if text == "P6SM" {
Some({ value: 6, unit: StatuteMiles, greater_than: true })
} else if text.has_suffix("SM") {
let body = text.sub(end=text.length() - 2)
match parse_unsigned(body) {
Some(value) => Some({ value, unit: StatuteMiles, greater_than: false })
None => None
}
} else if text.length() == 4 && is_digits(text) {
let value = parse_unsigned(text).unwrap()
Some({ value, unit: Meters, greater_than: value == 9999 })
} else {
None
}
}
///|
fn looks_like_rvr(text : String) -> Bool {
text.has_prefix("R") && text.contains("/")
}
///|
fn parse_rvr_trend(text : StringView) -> (StringView, RvrTrend?) {
if text.has_suffix("U") {
(text.sub(end=text.length() - 1), Some(RvrIncreasing))
} else if text.has_suffix("D") {
(text.sub(end=text.length() - 1), Some(RvrDecreasing))
} else if text.has_suffix("N") {
(text.sub(end=text.length() - 1), Some(RvrNoChange))
} else {
(text, None)
}
}
///|
fn parse_rvr_value(text : StringView) -> (Int, Bool, Bool)? {
let (body, above, below) = match text.strip_prefix("P") {
Some(rest) => (rest, true, false)
None =>
match text.strip_prefix("M") {
Some(rest) => (rest, false, true)
None => (text, false, false)
}
}
if body.length() == 4 && is_digits(body) {
Some((parse_unsigned(body).unwrap(), above, below))
} else {
None
}
}
///|
fn is_valid_runway_designator(text : StringView) -> Bool {
if text.length() == 2 && is_digits(text) {
true
} else if text.length() == 3 && is_digits(text.sub(end=2)) {
let suffix = text.sub(start=2)
suffix == "L" || suffix == "R" || suffix == "C"
} else {
false
}
}
///|
fn parse_rvr_token(token : Token) -> Result[RunwayVisualRange, Diagnostic] {
match token.lexeme.split_once("/") {
Some((runway_text, body_text)) => {
let runway_body = runway_text.sub(start=1)
if !runway_text.has_prefix("R") ||
!is_valid_runway_designator(runway_body) {
return Err(
error_at(
token,
InvalidRunwayVisualRange,
"RVR group must start with runway designator such as R27/ or R09L/",
),
)
}
let runway = runway_body.to_owned()
let (body, trend) = parse_rvr_trend(body_text)
match body.split_once("V") {
Some((minimum_text, maximum_text)) =>
match (parse_rvr_value(minimum_text), parse_rvr_value(maximum_text)) {
(
Some((minimum_meters, above1, below1)),
Some((maximum_meters, above2, below2)),
) =>
Ok({
runway,
minimum_meters,
maximum_meters: Some(maximum_meters),
above_maximum: above1 || above2,
below_minimum: below1 || below2,
trend,
})
_ =>
Err(
error_at(
token,
InvalidRunwayVisualRange,
"RVR variable range must use four digit meter values",
),
)
}
None =>
match parse_rvr_value(body) {
Some((minimum_meters, above, below)) =>
Ok({
runway,
minimum_meters,
maximum_meters: None,
above_maximum: above,
below_minimum: below,
trend,
})
None =>
Err(
error_at(
token,
InvalidRunwayVisualRange,
"RVR value must use four digit meters, optionally prefixed by P or M",
),
)
}
}
}
None =>
Err(
error_at(
token,
InvalidRunwayVisualRange,
"RVR group must contain a runway/value separator '/'",
),
)
}
}
///|
fn decode_weather(text : String) -> String? {
match text {
"RA" => Some("rain")
"-RA" => Some("light rain")
"+RA" => Some("heavy rain")
"BR" => Some("mist")
"FG" => Some("fog")
"TS" => Some("thunderstorm")
"TSRA" => Some("thunderstorm rain")
"-TSRA" => Some("light thunderstorm rain")
"+TSRA" => Some("heavy thunderstorm rain")
"SHRA" => Some("rain shower")
"-SHRA" => Some("light rain shower")
"+SHRA" => Some("heavy rain shower")
"DZ" => Some("drizzle")
"SN" => Some("snow")
"HZ" => Some("haze")
"NSW" => Some("no significant weather")
_ => None
}
}
///|
fn parse_weather_token(token : Token) -> Weather? {
match decode_weather(token.lexeme) {
Some(description) => {
let intensity = if token.lexeme.has_prefix("-") {
Light
} else if token.lexeme.has_prefix("+") {
Heavy
} else {
Moderate
}
Some({ raw: token.lexeme, intensity, description })
}
None => None
}
}
///|
fn coverage_from_prefix(prefix : StringView) -> CloudCoverage? {
match prefix {
"FEW" => Some(Few)
"SCT" => Some(Scattered)
"BKN" => Some(Broken)
"OVC" => Some(Overcast)
_ => None
}
}
///|
fn parse_cloud_token(token : Token) -> CloudLayer? {
let text = token.lexeme
match text {
"SKC" | "CLR" =>
Some({
coverage: SkyClear,
height_feet: None,
cumulonimbus: false,
towering_cumulus: false,
})
"NSC" =>
Some({
coverage: NoSignificantCloud,
height_feet: None,
cumulonimbus: false,
towering_cumulus: false,
})
"NCD" =>
Some({
coverage: NoCloudDetected,
height_feet: None,
cumulonimbus: false,
towering_cumulus: false,
})
_ => {
if text.length() < 6 {
return None
}
match coverage_from_prefix(text.sub(end=3)) {
Some(coverage) => {
let height_text = text.sub(start=3, end=6)
if !is_digits(height_text) {
return None
}
let suffix = text.sub(start=6)
let cumulonimbus = suffix.equal("CB")
let towering_cumulus = suffix.equal("TCU")
if !suffix.is_empty() && !cumulonimbus && !towering_cumulus {
return None
}
Some({
coverage,
height_feet: Some(parse_unsigned(height_text).unwrap() * 100),
cumulonimbus,
towering_cumulus,
})
}
None => None
}
}
}
}
///|
fn parse_temperature_token(token : Token) -> Temperature? {
match token.lexeme.split_once("/") {
Some((air, dew)) =>
match (parse_signed_metar_int(air), parse_signed_metar_int(dew)) {
(Some(air_celsius), Some(dew_point_celsius)) =>
Some({ air_celsius, dew_point_celsius })
_ => None
}
None => None
}
}
///|
fn parse_pressure_token(token : Token) -> Pressure? {
let text = token.lexeme
if text.length() == 5 && text.has_prefix("Q") && is_digits(text.sub(start=1)) {
Some({
value: parse_unsigned(text.sub(start=1)).unwrap(),
unit: HectoPascal,
})
} else if text.length() == 5 &&
text.has_prefix("A") &&
is_digits(text.sub(start=1)) {
Some({
value: parse_unsigned(text.sub(start=1)).unwrap(),
unit: InchesHgHundredths,
})
} else {
None
}
}
///|
fn default_time() -> ObservationTime {
{ day: 1, hour: 0, minute: 0 }
}
///|
fn classify_token(
token : Token,
report : MetarReport,
diagnostics : Array[Diagnostic],
) -> MetarReport {
if token.lexeme == "AUTO" {
return { ..report, automated: true }
}
if token.lexeme == "COR" {
return { ..report, correction: true }
}
if token.lexeme == "NOSIG" {
return { ..report, trend: Some(NoSignificantChange) }
}
if looks_like_wind(token.lexeme) {
match parse_wind_token(token) {
Ok(wind) => return { ..report, wind: Some(wind) }
Err(diagnostic) => {
diagnostics.push(diagnostic)
return report
}
}
}
match parse_variable_wind_token(token) {
Some(variable_wind) =>
return { ..report, variable_wind: Some(variable_wind) }
None => ()
}
match parse_visibility_token(token) {
Some(visibility) =>
return {
..report,
visibility: Some(visibility),
cavok: token.lexeme == "CAVOK",
}
None => ()
}
if looks_like_rvr(token.lexeme) {
match parse_rvr_token(token) {
Ok(rvr) => {
let rvr_items = report.rvr.copy()
rvr_items.push(rvr)
return { ..report, rvr: rvr_items }
}
Err(diagnostic) => {
diagnostics.push(diagnostic)
return report
}
}
}
match parse_weather_token(token) {
Some(weather) => {
let items = report.weather.copy()
items.push(weather)
return { ..report, weather: items }
}
None => ()
}
match parse_cloud_token(token) {
Some(cloud) => {
let clouds = report.clouds.copy()
clouds.push(cloud)
return { ..report, clouds, }
}
None => ()
}
match parse_temperature_token(token) {
Some(temperature) => return { ..report, temperature: Some(temperature) }
None => ()
}
match parse_pressure_token(token) {
Some(pressure) => return { ..report, pressure: Some(pressure) }
None => ()
}
diagnostics.push(warning_at(token, UnknownToken, "unrecognized METAR token"))
let remarks = report.remarks.copy()
remarks.push(token.lexeme)
{ ..report, remarks, }
}
///|
fn validate_metar_report(
report : MetarReport,
diagnostics : Array[Diagnostic],
) -> Unit {
match report.temperature {
Some(temp) =>
if temp.dew_point_celsius > temp.air_celsius {
diagnostics.push(
Diagnostic::new(
Warning,
InvalidTemperature,
"dew point is higher than air temperature",
empty_span(),
),
)
}
None => ()
}
let mut previous_height : Int? = None
for cloud in report.clouds {
match cloud.height_feet {
Some(height) => {
match previous_height {
Some(previous) =>
if height < previous {
diagnostics.push(
Diagnostic::new(
Warning,
InvalidCloud,
"cloud layer heights are not in ascending order",
empty_span(),
),
)
}
None => ()
}
previous_height = Some(height)
}
None => ()
}
}
}
///|
/// Parse a common METAR or SPECI report. The parser is deliberately tolerant:
/// unknown regional/remark tokens are preserved as remarks with warnings.
pub fn parse_metar(source : String) -> ParseResult[MetarReport] {
let diagnostics : Array[Diagnostic] = []
let tokens = tokenize(source)
if tokens.length() == 0 {
diagnostics.push(
Diagnostic::new(
Error,
EmptyInput,
"report must not be empty",
empty_span(),
),
)
return { value: None, diagnostics }
}
let mut index = 0
let kind = match tokens[0].lexeme {
"METAR" => {
index = 1
Metar
}
"SPECI" => {
index = 1
Speci
}
_ => UnspecifiedReport
}
if index >= tokens.length() {
diagnostics.push(
Diagnostic::new(
Error,
MissingStation,
"station code is missing",
empty_span(),
),
)
return { value: None, diagnostics }
}
let station_token = tokens[index]
if !is_station_code(station_token.lexeme) {
diagnostics.push(
error_at(
station_token,
InvalidStation,
"station code must be four uppercase letters/digits",
),
)
return { value: None, diagnostics }
}
index += 1
if index >= tokens.length() {
diagnostics.push(
Diagnostic::new(
Error,
MissingObservationTime,
"observation time is missing",
empty_span(),
),
)
return { value: None, diagnostics }
}
let time_token = tokens[index]
let observation_time = match parse_observation_time_token(time_token) {
Ok(value) => value
Err(diagnostic) => {
diagnostics.push(diagnostic)
default_time()
}
}
index += 1
let mut report : MetarReport = {
kind,
station: station_token.lexeme,
observation_time,
automated: false,
correction: false,
wind: None,
variable_wind: None,
visibility: None,
rvr: [],
cavok: false,
weather: [],
clouds: [],
temperature: None,
pressure: None,
trend: None,
remarks: [],
}
let mut in_remarks = false
while index < tokens.length() {
let token = tokens[index]
if in_remarks {
let remarks = report.remarks.copy()
remarks.push(token.lexeme)
report = { ..report, remarks, }
} else if token.lexeme == "RMK" {
in_remarks = true
} else {
report = classify_token(token, report, diagnostics)
}
index += 1
}
validate_metar_report(report, diagnostics)
{ value: Some(report), diagnostics }
}
///|
pub fn parse_speci(source : String) -> ParseResult[MetarReport] {
parse_metar(source)
}