Bài viết được sự cho phép của tác giả Huy Trần
Khi dùng Google Calendar hoặc chúng tôi của macOS, mình rất thích chức năng tạo nhanh một event bằng cách nhập vào nội dung một cách tự nhiên như khi đang nói, ví dụ:
get hair cut at 10am every Sunday
hoặc là
doctor appointment at 1pm on Monday
Khi nhận được input như này, một event mới sẽ được tạo ra với ngày và giờ tương ứng, còn nội dung của event sẽ là phần text mở đầu, ví dụ “get hair cut”, hoặc “doctor appointment”.
Thường thì cái gì mình thích, mình sẽ tìm cách clone lại, chức năng này cũng ko ngoại lệ.
Vì bài viết này là phần tiếp theo của bài viết trước, chúng ta sẽ tiếp tục dùng Rust và Nom. Recommend các bạn đọc kĩ phần trước, và chuẩn bị những kiến thức cơ bản của Rust, nhất là về Result
, Option
, cargo test
, trước khi đọc tiếp bài này.
Phân tích cú pháp
Đây không phải là cú pháp mà Google Calendar hay chúng tôi sử dụng, nhưng đây sẽ là cú pháp chúng ta sẽ dùng trong bài viết này, đơn giản là vì nó… đơn giản
Cấu trúc của một event input là một tổ hợp của nhiều token như sau:
Trong đó:
Nếu chỉ có giá trị giờ (hour
) mà không có phút (minutes
) thì mặc định sẽ là 00
phút.
Để cho đơn giản thì chúng ta sẽ tạm bỏ qua việc xác thực nội dung nhập vào, nên những case như thế này vẫn sẽ được chấp nhận:
32:42
pm
ho
ặc
24:59
Trên thực tế, validation cũng không phải là nhiệm vụ của parser.
Thuật toán parse event
Vậy ta sẽ parse nội dung như thế nào sau khi đã xác định được 3 thành phần trên?
Thuật toán parse của chúng ta sẽ duyệt từng kí tự từ đầu đến cuối string, và tìm cách parse ra từng token.
Implementation
Xác định xong cú pháp và thuật toán là coi như giải quyết xong 75% bài toán rồi, 95% còn lại nằm ở việc đánh nhau với Rust bây giờ ta implement thôi.
Việc đầu tiên là define ra cấu trúc dữ liệu cho kết quả sau khi parse, ta sẽ gọi nó là ReminderEvent
:
text:
String
,
}
Trường text
là nội dung của event, kiểu dữ liệu cho date
và time
lần lượt sẽ là:
content: &
‘a
str
,
repeated:
bool
}
hour: &
‘a
str
,
minute: &
‘a
str
,
meridiem:
bool
}
Một ReminderDate
sẽ chứa content
là một chuỗi thô chưa qua xử lý, ví dụ như “Tuesday”, “14/12”. Bao giờ cần xài thì parse tiếp sau, trong bài viết này mình sẽ lưu raw. Nếu đây là một event diễn ra định kì (có chữ “every” khi parse date), thì trường repeated
sẽ là true
.
Một ReminderTime
chứa các giá trị giờ / phút dưới dạng chuỗi thô, giá trị meridiem indicator sẽ được lưu trong trường meridiem
, vì nó có thể có, có thể không tồn tại trong input, mặc định sẽ là true
cho “am”, và false
cho “pm”.
Tiếp theo, kế hoạch thực hiện sẽ là:
- Viết hàm parse
ReminderTime
- Viết hàm parse
ReminderDate
- Sau đó kết hợp 2 hàm này để viết hàm parse
ReminderEvent
Hàm parse ReminderTime
}
fn
test_parse_time
() {
let
test_times = [
("at 11:00"
, Ok
(("11"
, "00"
, true
))),
("at 10pm"
, Ok
(("10"
, "00"
, false
))),
("at 12:13 am"
, Ok
(("12"
, "13"
, true
))),
("13:42pm"
, Ok
(("13"
, "42"
, false
))),
("15:30"
, Ok
(("15"
, "30"
, true
))),
("at 5"
, Ok
(("5"
, "00"
, true
))),
("32:412"
, Ok
(("32"
, "412"
, true
))),
("at 32:281am"
, Ok
(("32"
, "281"
, true
))),
("at 32pm"
, Ok
(("32"
, "00"
, false
))),
("night time"
, Err
(())),
("at night"
, Err
(())),
];
for
test_case in
test_times {
let
result = parse_time(test_case.0
);
if
test_case.1
.is_ok() {
let
(_, actual) = result.unwrap();
let
expected = test_case.1
.unwrap();
assert_eq!
(actual.hour, expected.0
);
assert_eq!
(actual.minute, expected.1
);
assert_eq!
(actual.meridiem, expected.2
);
} else
{
assert!
(result.is_err());
}
}
}
Trong hàm test_parse_time()
ở trên, ta tạo một mảng test_times
chứa tập các tuple dạng (input, expected_result)
. Trong đó, các input
là các string mà trên thực tế sẽ được nhập vào từ phía user, expected_result
là một giá trị kiểu Result
, nếu nó là giá trị Ok(...)
có nghĩa là input này parse được, còn giá trị Err()
là trường hợp lỗi. Mỗi giá trị Ok(...)
sẽ có dạng (hour, minute, meridiem)
. Trong vòng lặp tiếp theo của hàm test, ta duyệt qua từng test case, lấy ra giá trị thực tế actual
từ parser (kiểu ReminderTime
) và so sánh nó với từng giá trị trong tuple test.
Phương pháp này không có gì mới, bên Golang gọi là table driven testing.
Trước khi đi vào giải thích, đây là hàm parse_time
hoàn chỉnh:
let
(remain, (_, _, hour, opt_min, _, am)) = tuple((
opt(tag(
“at”
)),
multispace0,
digit1,
opt(tuple((tag(
“:”
), digit1))),
multispace0,
opt(alt((tag(
“am”
), tag(
“pm”
)))),
))
.parse(input)?;
let
(_, minute) = opt_min.unwrap_or((
“”
,
“00”
));
Ok
((remain, ReminderTime { hour, minute, meridiem }))
}
Để đọc chuỗi “at”, ta có thể dùng hàm tag()
của nom, vì “at” có thể có hoặc không xuất hiện trong input, ta dùng hàm opt()
để cho nom biết nó là một giá trị optional.
opt(tag
("at"
))
Tiếp sau “at” là một hoặc nhiều kí tự space, ta dùng multispace0
để xác định nó. Sau đó là hour
, cấu thành từ một hoặc nhiều kí tự số, ta dùng digit1
để parse.
Phần giá trị phút minute
, thường sẽ đi sau hour
, cách nhau bằng một kí tự ':'
, nó cũng là optional.
opt(tuple
(tag
(":"
), digit1))
Tiếp theo lại là một mớ khoảng trắng nếu có, tiếp tục dùng multispace0
.
Cuối cùng là phần meridiem indicator, có thể có hoặc không, ta dùng hàm alt()
để parse lấy một trong 2 giá trị tag("am")
và tag("pm")
.
opt(alt
((tag
("am"
), tag("pm"
))))
Các giá trị sau khi parse được lưu vào một tuple, mỗi phần tử của tuple sẽ được gán với một biến, lần lượt là remain
, hour
, opt_min
, am
. Trong đó remain
là phần chuỗi còn sót lại sau quá trình parse.
Vì phần minute
là một giá trị optional, kiểu Option
, ta cần một bước hậu xử lý để nếu nó là None
thì mặc định nó thành giá trị "00"
.
let
(_, minute) = opt_min.unwrap_or((""
, "00"
));
Tương tự, giá trị am
cũng là optional, nhưng ta sẽ có 2 trường hợp: Hoặc nó là Some("am")
, hoặc nó là None
, nếu đúng thì giá trị meridiem
của kết quả trả về sẽ là true
, ngược lại nó là false
.
Cuối cùng, nếu mọi thứ ok hết, ta tạo ra một object ReminderTime
từ các giá trị đã được xử lý ở trên, và trả về kết quả.
Chạy thử hàm test, ta sẽ thấy mọi thứ đều pass hết, lý do không phải vì test lụi, mà là vì chúng ta code xịn, chả có mấy khi, cứ tự tin lên.
running 1 test
test test_parse_time ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Hàm parse ReminderDate
Tiếp theo là hàm parse parse_date
, như trên, ta cũng bắt đầu bằng:
}
Và tiếp theo là viết hàm test_parse_date()
:
fn
test_parse_date
() {
(" every Sunday"
, Ok
(("Sunday"
, true
))),
("every Monday"
, Ok
(("Monday"
, true
))),
("on Tuesday "
, Ok
(("Tuesday"
, false
))),
("tomorrow"
, Ok
(("tomorrow"
, false
))),
("today"
, Ok
(("today"
, false
))),
("on 08/25"
, Ok
(("08/25"
, false
))),
("every 3rd"
, Ok
(("3rd"
, true
))),
(""
, Ok
(("today"
, false
)))
];
for
test_case in
test_cases {
let
result = parse_date(test_case.0
);
if
test_case.1
.is_ok() {
let
(_, actual) = result.unwrap();
let
expected = test_case.1
.unwrap();
assert_eq!
(actual.content, expected.0
);
assert_eq!
(actual.repeated, expected.1
);
} else
{
assert!
(result.is_err());
}
}
}
Khỏi cần giải thích dài dòng, cơ bản vì mình lười viết, nên để phần này cho các bạn tự suy luận
Implement đầy đủ của hàm parse_date
như sau:
let
(remain, (_, opt_repeat, _, date)) = tuple((
multispace0,
opt(alt((value(
true
, tag(
“every”
)), value(
false
, tag(
“on”
))))),
multispace0,
rest
))
.parse(input)?;
let
repeated = opt_repeat.unwrap_or(
false
);
let
content =
if
date.trim().is_empty() {
“today”
}
else
{ date.trim() };
Ok
((remain, ReminderDate { content, repeated }))
}
opt(alt((value(true
, tag("every"
)), value(false
, tag("on"
)))))
Phần còn lại của input sẽ là phần ReminderDate.content
, chúng ta chỉ việc lấy ra dưới dạng raw string, xài rest
combinator để lấy toàn bộ ra. Nếu phần content là một chuỗi rỗng (empty), thì ta sẽ mặc định nó thành “today”.
Cuối cùng là khởi tạo object ReminderDate
rồi trả về kết quả.
Lại tiếp tục chạy test để thấy khả năng code thần sầu của tác giả:
running 1 test
test test_parse_date ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s
Hàm parse ReminderEvent
Xem lại cú pháp của một input event hoàn chỉnh:
Đến đây hẳn các bạn cũng đoán được logic của hàm parse_event()
, nhưng mà khoan đã, việc đầu tiên là define ra cái hàm:
}
Và viết test:
fn
test_parse_event
() {
("go feed the fish at 10am"
, Ok
(("go feed the fish"
, "10"
, "00"
, true
, "today"
, false
))),
("feed the fish at 10:00am"
, Ok
(("feed the fish"
, "10"
, "00"
, true
, "today"
, false
))),
("walk the dog 10:00am today"
, Ok
(("walk the dog"
, "10"
, "00"
, true
, "today"
, false
))),
("feed the cat at 4 tomorrow"
, Ok
(("feed the cat"
, "4"
, "00"
, true
, "tomorrow"
, false
))),
("get haircut at 14:24 pm"
, Ok
(("get haircut"
, "14"
, "24"
, false
, "today"
, false
))),
("credit card pay at 8am"
, Ok
(("credit card pay"
, "8"
, "00"
, true
, "today"
, false
))),
("credit card pay at 8:00 every 20th"
, Ok
(("credit card pay"
, "8"
, "00"
, true
, "20th"
, true
))),
("cafe with Justin at Ginza at 6 on 08/23"
, Ok
(("cafe with Justin at Ginza"
, "6"
, "00"
, true
, "08/23"
, false
))),
("pick up books at library at 10am every Sunday"
, Ok
(("pick up books at library"
, "10"
, "00"
, true
, "Sunday"
, true
))),
("lorem ipsum doro tata"
, Err
(()))
];
for
test_case in
test_events {
let
result = parse_event(test_case.0
);
if
test_case.1
.is_ok() {
let
(_, actual) = result.unwrap();
let
expected = test_case.1
.unwrap();
assert_eq!
(actual.text, expected.0
);
assert_eq!
(actual.time.hour, expected.1
);
assert_eq!
(actual.time.minute, expected.2
);
assert_eq!
(actual.time.meridiem, expected.3
);
assert_eq!
(actual.date.content, expected.4
);
assert_eq!
(actual.date.repeated, expected.5
);
} else
{
assert!
(result.is_err());
}
}
}
Mảng test_events
chứa các tuple có cấu trúc dạng:
Trong đó input
là nội dung event input sẽ được nhập vào từ user, và các field trong Result sẽ là các field tương ứng của object ReminderEvent
, ReminderTime
và ReminderDate
.
Bằng việc kết hợp các hàm parse_time()
và parse_date()
, ta có thể implement hàm parse_event()
một cách rất đơn giản:
let
(input, (vtask, (time, date))) =
many_till(anychar, pair(parse_time, parse_date)).parse(input)?;
let
text = vtask
.iter()
.join(
“”
)
.trim()
.to_string();
Ok
((input, ReminderEvent { text, time, date }))
}
Cuối cùng, chạy lại toàn bộ test:
running 3 tests
test test_parse_date ... ok
test test_parse_time ... ok
test test_parse_event ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Như vậy chúng ta đã hoàn thành việc build một parser hết sức đơn giản, để parse một input event dưới dạng ngôn ngữ tự nhiên thành một data struct hoàn chỉnh. Từ đây ta có thể xây dựng được những ứng dụng triệu đô, dư sức cạnh tranh với Google Calendar hay chúng tôi (tự tin không ai đánh thuế mà).
Input:
"write new blog post at 9am every 14th Dec"
Output:
ReminderEvent{
text:
"write new blog post"
,
date:
ReminderDate{
content:
"14th Dec"
,
repeated:
true
},
time:
ReminderTime{
hour:
"9"
,
minute:
"00"
,
meridiem:
true
}
}
Hẹn gặp lại các bạn trong các bài viết tiếp theo.