サンプルコードで身につける: 属性の 8 つの状態 — 引用符 3 種類と、値のない属性
解説で学んだ概念を、3つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1引用符 3 種を状態で区別する
の値は "..."、'...'、引用符なし、の 3 通りで、HTML Standard 13.2.5.36〜38 はそれぞれ別のです。BeforeAttributeValue で「=」の次の 1 文字を見てどの状態に入るかを決め、その状態では対応する閉じ引用符だけが値の終わりになります(引用符なしは空白か > で終わる)。実行すると href="/x" title='say "hi"' target=_blank から href = "/x"、title = "say \"hi\""(単引用符の中の二重引用符は文字)、target = "_blank" の 3 つが取れます。
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
BeforeAttributeName, // 13.2.5.32 タグ名のあと、属性名の前
AttributeName, // 13.2.5.33 属性名の途中
BeforeAttributeValue, // 13.2.5.35 "=" のあと、値の前
AttributeValueDoubleQuoted, // 13.2.5.36 "..." の中
AttributeValueSingleQuoted, // 13.2.5.37 '...' の中
AttributeValueUnquoted, // 13.2.5.38 引用符なしの値の中
AfterAttributeValueQuoted, // 13.2.5.39 閉じ引用符のあと
}
#[derive(Debug)]
struct Attribute {
name: String,
value: String,
}
/// タグ名の直後から ">" までを読んで属性の列にする(3 種類の引用符を状態で区別する)
fn read_attributes(s: &str) -> Vec<Attribute> {
let chars: Vec<char> = s.chars().collect();
let mut attrs: Vec<Attribute> = Vec::new();
let mut state = State::BeforeAttributeName;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match state {
State::BeforeAttributeName => match c {
' ' => {}
'>' => break,
_ => {
attrs.push(Attribute { name: String::new(), value: String::new() }); // 新しい属性を開始
state = State::AttributeName;
continue; // この文字は AttributeName で読み直す(reconsume)
}
},
State::AttributeName => match c {
'=' => state = State::BeforeAttributeValue,
_ => attrs.last_mut().unwrap().name.push(c),
},
// "=" の次の 1 文字で、どの引用符の状態に入るかが決まる
State::BeforeAttributeValue => match c {
'"' => state = State::AttributeValueDoubleQuoted,
'\'' => state = State::AttributeValueSingleQuoted,
_ => {
state = State::AttributeValueUnquoted;
continue;
}
},
State::AttributeValueDoubleQuoted => match c {
'"' => state = State::AfterAttributeValueQuoted, // 対応する閉じ引用符だけが終わり
_ => attrs.last_mut().unwrap().value.push(c),
},
State::AttributeValueSingleQuoted => match c {
'\'' => state = State::AfterAttributeValueQuoted,
_ => attrs.last_mut().unwrap().value.push(c),
},
State::AttributeValueUnquoted => match c {
' ' => state = State::BeforeAttributeName, // 引用符なしは空白で終わる
'>' => break,
_ => attrs.last_mut().unwrap().value.push(c),
},
State::AfterAttributeValueQuoted => match c {
' ' => state = State::BeforeAttributeName,
'>' => break,
_ => {
state = State::BeforeAttributeName; // 引用符の直後に空白が無い(parse error だが続ける)
continue;
}
},
}
i += 1;
}
attrs
}
fn main() {
// <a href="/x" title='it's ok' target=_blank> のタグ名より後ろ
let tail = "href=\"/x\" title='say \"hi\"' target=_blank>";
for a in read_attributes(tail) {
println!("{:<8} = {:?}", a.name, a.value);
}
}
2値なし属性・空白・小文字化
AfterAttributeName(13.2.5.34)を足すと、「=」が来なかった属性を値なし(値は空文字)として確定して次の属性に進めます。タグ名と属性名は小文字に揃えて値はそのまま残し、「=」の前後の空白や改行、引用符の直後に空白が無い class="a"id="b"( だが続ける)も読めます。実行すると <INPUT DISABLED required Value=Yes> が input / disabled="" required="" value="Yes"、改行入りの a href = "/x" ID="Main" が href="/x" id="Main"、p class="a"id="b" が class="a" id="b" と出ます。
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
TagName,
BeforeAttributeName,
AttributeName,
AfterAttributeName, // 13.2.5.34 属性名のあと。"=" が来るか、次の属性が来るか、">" が来るか
BeforeAttributeValue,
AttributeValueDoubleQuoted,
AttributeValueUnquoted,
AfterAttributeValueQuoted,
}
struct Attribute { name: String, value: String }
fn is_whitespace(c: char) -> bool {
matches!(c, '\t' | '\n' | '\x0C' | ' ')
}
/// "<" の次の文字から ">" までを読む。戻り値は (タグ名, 属性の列)
fn read_tag(s: &str) -> (String, Vec<Attribute>) {
let chars: Vec<char> = s.chars().collect();
let mut tag = String::new();
let mut attrs: Vec<Attribute> = Vec::new();
let mut state = State::TagName;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let mut reconsume = false; // true のときは i を進めず、同じ文字を次の状態で読み直す
match state {
State::TagName => match c {
c if is_whitespace(c) => state = State::BeforeAttributeName,
'>' => break,
_ => tag.push(c.to_ascii_lowercase()), // タグ名は小文字に揃える
},
State::BeforeAttributeName => match c {
c if is_whitespace(c) => {}
'>' => break,
_ => { attrs.push(Attribute { name: String::new(), value: String::new() }); state = State::AttributeName; reconsume = true; } // 新しい属性を始める
},
State::AttributeName => match c {
c if is_whitespace(c) || c == '>' => { state = State::AfterAttributeName; reconsume = true; }
'=' => state = State::BeforeAttributeValue,
_ => attrs.last_mut().unwrap().name.push(c.to_ascii_lowercase()), // 属性名も小文字に
},
State::AfterAttributeName => match c {
c if is_whitespace(c) => {}
'=' => state = State::BeforeAttributeValue, // 空白を挟んだ "=" もここで拾う
'>' => break,
_ => {
// "=" が来なかった = 値なし属性(disabled など)。値は空のまま、次の属性を始める
attrs.push(Attribute { name: String::new(), value: String::new() });
state = State::AttributeName;
reconsume = true;
}
},
State::BeforeAttributeValue => match c {
c if is_whitespace(c) => {}
'"' => state = State::AttributeValueDoubleQuoted,
_ => { state = State::AttributeValueUnquoted; reconsume = true; }
},
State::AttributeValueDoubleQuoted => match c {
'"' => state = State::AfterAttributeValueQuoted,
_ => attrs.last_mut().unwrap().value.push(c), // 値は小文字にしない
},
State::AttributeValueUnquoted => match c {
c if is_whitespace(c) => state = State::BeforeAttributeName,
'>' => break,
_ => attrs.last_mut().unwrap().value.push(c),
},
State::AfterAttributeValueQuoted => match c {
c if is_whitespace(c) => state = State::BeforeAttributeName,
'>' => break,
_ => { state = State::BeforeAttributeName; reconsume = true; } // 引用符の直後に空白が無い(parse error)。続ける
},
}
if !reconsume { i += 1; }
}
(tag, attrs)
}
fn main() {
for tag in ["INPUT DISABLED required Value=Yes>", "a href = \"/x\"\n ID=\"Main\">", "p class=\"a\"id=\"b\">"].iter() {
let (name, attrs) = read_tag(tag);
let shown: Vec<String> = attrs.iter().map(|a| format!("{}={:?}", a.name, a.value)).collect();
println!("<{name}> {}", shown.join(" "));
}
}
3実務: logfmt のログを同じ状態で読む
level=info msg="user logged in" user=kazu のような key=value 形式(logfmt)は、HTML の属性と同じ状態の並び(BeforeKey / Key / BeforeValue / 引用符 2 種 / Unquoted)で読め、値なしのキー(retry)は HTML の値なし属性と同じ扱いです。実行すると 3 行のログがそれぞれ 3 組・5 組・3 組に分かれ、level=error の行だけ msg(引用符の中のコロンと空白が保たれる)と retry の有無が抜き出されます。split(' ') では msg が壊れる入力です。
/// logfmt 形式(key=value を空白で並べたログ)を読む。HTML の属性と同じ状態の並びで書ける
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
BeforeKey,
Key,
BeforeValue,
DoubleQuoted,
SingleQuoted,
Unquoted,
}
fn parse_logfmt(line: &str) -> Vec<(String, String)> {
let chars: Vec<char> = line.chars().collect();
let mut pairs: Vec<(String, String)> = Vec::new();
let mut state = State::BeforeKey;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let mut reconsume = false;
match state {
State::BeforeKey => match c {
' ' => {}
_ => {
pairs.push((String::new(), String::new()));
state = State::Key;
reconsume = true;
}
},
State::Key => match c {
'=' => state = State::BeforeValue,
' ' => state = State::BeforeKey, // 値なしのキー(フラグ)。値は空のまま
_ => pairs.last_mut().unwrap().0.push(c),
},
State::BeforeValue => match c {
'"' => state = State::DoubleQuoted,
'\'' => state = State::SingleQuoted,
_ => {
state = State::Unquoted;
reconsume = true;
}
},
State::DoubleQuoted => match c {
'"' => state = State::BeforeKey,
_ => pairs.last_mut().unwrap().1.push(c),
},
State::SingleQuoted => match c {
'\'' => state = State::BeforeKey,
_ => pairs.last_mut().unwrap().1.push(c),
},
State::Unquoted => match c {
' ' => state = State::BeforeKey,
_ => pairs.last_mut().unwrap().1.push(c),
},
}
if !reconsume {
i += 1;
}
}
pairs
}
fn main() {
let log = "\
level=info msg=\"user logged in\" user=kazu
level=error msg='db: connection refused' retry user=kazu elapsed=1.5s
level=info msg=\"job done\" count=12";
for line in log.lines() {
let pairs = parse_logfmt(line);
let get = |k: &str| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
// error の行だけ、msg と retry の有無を取り出す
if get("level") == Some("error") {
println!("error: {:?} retry={}", get("msg").unwrap_or(""), get("retry").is_some());
}
println!(" {} 組: {pairs:?}", pairs.len());
}
}