Class: HeadMusic::Notation::ABC::BodyLexer
- Inherits:
-
Object
- Object
- HeadMusic::Notation::ABC::BodyLexer
- Defined in:
- lib/head_music/notation/abc/body_lexer.rb
Overview
Tokenizes the body of an ABC tune (the text after the K: header line).
Emits flat value tokens rather than a tree so the parser can decide how much structure to build. Out-of-scope-but-valid ABC constructs are emitted as :unsupported tokens (instead of raising here) so the parser can raise UnsupportedFeatureError with knowledge of what it was.
Defined Under Namespace
Classes: Token
Constant Summary collapse
- BAR_LINE_PATTERN =
Alternatives are ordered longest-first so the scanner never takes a short match when a longer bar line is present (e.g. “|]” before “|”).
/:\|\|:|:\|:|::|:\||\|:|\|\||\|\]|\[\||\|/- NORMALIZED_BAR_STYLES =
”: :” and “: :” are alternate spellings of the double repeat “::”. {":||:" => "::", ":|:" => "::"}.freeze
- NOTE_PATTERN =
%r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}- REST_PATTERN =
%r{z([\d/]*)}- VOLTA_DIGITS_PATTERN =
/\d[\d,-]*/- SNIPPET_LENGTH =
20
Instance Method Summary collapse
-
#ensure_valid_encoding ⇒ Object
private
-
#header_field_line?(line_text) ⇒ Boolean
private
A note followed by a repeat bar (e.g. “A: ”) also puts a colon after a letter at line start, so bar-line characters after the colon disqualify. -
#initialize(body_text, start_line: 1) ⇒ BodyLexer
constructor
A new instance of BodyLexer.
-
#lex ⇒ Object
private
-
#line_start_token(line_text, line_number, tokens) ⇒ Object
private
Handles lines that are fields rather than music: V: switches voices; any other letter-colon line is a field we don’t interpret in the body.
-
#raise_unexpected_character(scanner, line_number, column) ⇒ Object
private
-
#scan_bar_line(scanner, line_number, column, tokens) ⇒ Object
private
-
#scan_bracket(scanner, line_number, column, tokens) ⇒ Object
private
-
#scan_broken_rhythm(scanner, line_number, column, tokens) ⇒ Object
private
-
#scan_line(line_text, line_number, tokens) ⇒ Object
private
Returns true when the line ends with a continuation backslash.
-
#scan_note(scanner, line_number, column, tokens) ⇒ Object
private
-
#scan_quoted_string(scanner, line_number, column, tokens) ⇒ Object
private
Quoted chord symbols are consumed whole so a “%” inside quotes is never mistaken for a comment.
-
#scan_rest(scanner, line_number, column, tokens) ⇒ Object
private
-
#scan_token(scanner, line_number, tokens) ⇒ Object
private
-
#scan_trailing_volta(scanner, line_number, tokens) ⇒ Object
private
After a bar line, an immediately following digit begins a volta (the “ 1” / “: 2” shorthands for first and second endings). -
#scan_unsupported(scanner, line_number, column, tokens) ⇒ Object
private
Recognizable ABC we deliberately don’t handle: grace notes, decorations, tuplets, slurs, ties, and special rests.
-
#tokens ⇒ Object
-
#unsupported_token(lexeme, line_number, column) ⇒ Object
private
-
#voice_change_token(line_text, line_number) ⇒ Object
private
-
#volta_passes(digits) ⇒ Object
private
-
#volta_token(digits, line_number, column) ⇒ Object
private
Constructor Details
#initialize(body_text, start_line: 1) ⇒ BodyLexer
Returns a new instance of BodyLexer.
39 40 41 42 43 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 39 def initialize(body_text, start_line: 1) @body = body_text.to_s @start_line = start_line ensure_valid_encoding end |
Instance Method Details
#ensure_valid_encoding ⇒ Object (private)
51 52 53 54 55 56 57 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 51 def ensure_valid_encoding return if @body.valid_encoding? raise HeadMusic::Notation::ABC::ParseError.new( "Tune body is not valid UTF-8", line_number: @start_line ) end |
#header_field_line?(line_text) ⇒ Boolean (private)
A note followed by a repeat bar (e.g. “A:|”) also puts a colon after a letter at line start, so bar-line characters after the colon disqualify.
93 94 95 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 93 def header_field_line?(line_text) line_text.match?(/\A[A-Za-z]:/) && !["|", ":"].include?(line_text[2]) end |
#lex ⇒ Object (private)
59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 59 def lex tokens = [] continued = false @body.lines.map(&:chomp).each_with_index do |line_text, index| break if line_text.strip.empty? line_number = @start_line + index if !continued && line_start_token(line_text, line_number, tokens) next end continued = scan_line(line_text, line_number, tokens) end tokens end |
#line_start_token(line_text, line_number, tokens) ⇒ Object (private)
Handles lines that are fields rather than music: V: switches voices; any other letter-colon line is a field we don’t interpret in the body.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 76 def line_start_token(line_text, line_number, tokens) if line_text.start_with?("V:") tokens << voice_change_token(line_text, line_number) return true end if header_field_line?(line_text) tokens << Token.new( type: :unsupported, line: line_number, column: 1, lexeme: line_text.strip[0, SNIPPET_LENGTH] ) return true end false end |
#raise_unexpected_character(scanner, line_number, column) ⇒ Object (private)
256 257 258 259 260 261 262 263 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 256 def raise_unexpected_character(scanner, line_number, column) character = scanner.peek(1) raise HeadMusic::Notation::ABC::ParseError.new( "Unexpected character #{character.inspect} at column #{column}", line_number: line_number, snippet: scanner.rest[0, SNIPPET_LENGTH] ) end |
#scan_bar_line(scanner, line_number, column, tokens) ⇒ Object (private)
139 140 141 142 143 144 145 146 147 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 139 def (scanner, line_number, column, tokens) lexeme = scanner.scan(BAR_LINE_PATTERN) return false unless lexeme style = NORMALIZED_BAR_STYLES.fetch(lexeme, lexeme) tokens << Token.new(type: :bar_line, line: line_number, column: column, style: style) scan_trailing_volta(scanner, line_number, tokens) true end |
#scan_bracket(scanner, line_number, column, tokens) ⇒ Object (private)
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 159 def scan_bracket(scanner, line_number, column, tokens) return false unless scanner.check(/\[/) digits = scanner.scan(/\[(\d[\d,-]*)/) if digits tokens << volta_token(scanner[1], line_number, column) return true end inline_field = scanner.scan(/\[[A-Za-z]:[^\]]*\]/) || scanner.scan(/\[[A-Za-z]:[^\]]*/) if inline_field tokens << unsupported_token(inline_field, line_number, column) return true end if scanner.check(/\[[A-Ga-g]/) chord = scanner.scan(/\[[^\]]*\]/) || scanner.scan(/\[[^\]]*/) tokens << unsupported_token(chord, line_number, column) return true end raise_unexpected_character(scanner, line_number, column) end |
#scan_broken_rhythm(scanner, line_number, column, tokens) ⇒ Object (private)
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 219 def scan_broken_rhythm(scanner, line_number, column, tokens) # Doubled marks (">>", "<<") are valid ABC (double-dotted broken # rhythm) but out of scope, so they surface as unsupported rather # than as a malformed-input error. doubled = scanner.scan(/[<>]{2,}/) if doubled tokens << unsupported_token(doubled, line_number, column) return true end lexeme = scanner.scan(/[<>]/) return false unless lexeme tokens << Token.new( type: :broken_rhythm, line: line_number, column: column, direction: lexeme.to_sym ) true end |
#scan_line(line_text, line_number, tokens) ⇒ Object (private)
Returns true when the line ends with a continuation backslash.
103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 103 def scan_line(line_text, line_number, tokens) scanner = StringScanner.new(line_text) until scanner.eos? next if scanner.skip(/[ \t]+/) break if scanner.skip(/%/) return true if scanner.skip(/\\[ \t]*\z/) scan_token(scanner, line_number, tokens) end false end |
#scan_note(scanner, line_number, column, tokens) ⇒ Object (private)
201 202 203 204 205 206 207 208 209 210 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 201 def scan_note(scanner, line_number, column, tokens) return false unless scanner.scan(NOTE_PATTERN) tokens << Token.new( type: :note, line: line_number, column: column, accidental: scanner[1], letter: scanner[2], octave_marks: scanner[3], length: scanner[4] ) true end |
#scan_quoted_string(scanner, line_number, column, tokens) ⇒ Object (private)
Quoted chord symbols are consumed whole so a “%” inside quotes is never mistaken for a comment.
131 132 133 134 135 136 137 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 131 def scan_quoted_string(scanner, line_number, column, tokens) lexeme = scanner.scan(/"[^"]*"/) || scanner.scan(/"[^"]*/) return false unless lexeme tokens << unsupported_token(lexeme, line_number, column) true end |
#scan_rest(scanner, line_number, column, tokens) ⇒ Object (private)
212 213 214 215 216 217 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 212 def scan_rest(scanner, line_number, column, tokens) return false unless scanner.scan(REST_PATTERN) tokens << Token.new(type: :rest, line: line_number, column: column, length: scanner[1]) true end |
#scan_token(scanner, line_number, tokens) ⇒ Object (private)
115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 115 def scan_token(scanner, line_number, tokens) column = scanner.pos + 1 return if scan_quoted_string(scanner, line_number, column, tokens) return if (scanner, line_number, column, tokens) return if scan_bracket(scanner, line_number, column, tokens) return if scan_note(scanner, line_number, column, tokens) return if scan_rest(scanner, line_number, column, tokens) return if scan_broken_rhythm(scanner, line_number, column, tokens) return if scan_unsupported(scanner, line_number, column, tokens) raise_unexpected_character(scanner, line_number, column) end |
#scan_trailing_volta(scanner, line_number, tokens) ⇒ Object (private)
After a bar line, an immediately following digit begins a volta (the “|1” / “:|2” shorthands for first and second endings).
151 152 153 154 155 156 157 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 151 def scan_trailing_volta(scanner, line_number, tokens) return unless scanner.check(/\d/) column = scanner.pos + 1 digits = scanner.scan(VOLTA_DIGITS_PATTERN) tokens << volta_token(digits, line_number, column) end |
#scan_unsupported(scanner, line_number, column, tokens) ⇒ Object (private)
Recognizable ABC we deliberately don’t handle: grace notes, decorations, tuplets, slurs, ties, and special rests.
240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 240 def scan_unsupported(scanner, line_number, column, tokens) lexeme = scanner.scan(/\{[^}]*\}/) || scanner.scan(/\{[^}]*/) || scanner.scan(/![^!]*!/) || scanner.scan(/![^!]*/) || scanner.scan(/\(\d/) || scanner.scan(/[()\-~.]/) || scanner.scan(/Z\d*/) || scanner.scan(%r{x[\d/]*}) return false unless lexeme tokens << unsupported_token(lexeme, line_number, column) true end |
#tokens ⇒ Object
45 46 47 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 45 def tokens @tokens ||= lex end |
#unsupported_token(lexeme, line_number, column) ⇒ Object (private)
252 253 254 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 252 def unsupported_token(lexeme, line_number, column) Token.new(type: :unsupported, line: line_number, column: column, lexeme: lexeme) end |
#voice_change_token(line_text, line_number) ⇒ Object (private)
97 98 99 100 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 97 def voice_change_token(line_text, line_number) voice_id = line_text.delete_prefix("V:").split("%", 2).first.to_s.strip Token.new(type: :voice_change, line: line_number, column: 1, voice_id: voice_id) end |
#volta_passes(digits) ⇒ Object (private)
194 195 196 197 198 199 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 194 def volta_passes(digits) digits.split(",").flat_map do |part| first, last = part.split("-", 2) last ? (first.to_i..last.to_i).to_a : [first.to_i] end.select(&:positive?) end |
#volta_token(digits, line_number, column) ⇒ Object (private)
183 184 185 186 187 188 189 190 191 192 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 183 def volta_token(digits, line_number, column) passes = volta_passes(digits) unless passes.uniq.length == passes.length raise HeadMusic::Notation::ABC::ParseError.new( "Volta passes must be unique", line_number: line_number, snippet: digits ) end Token.new(type: :volta, line: line_number, column: column, passes: passes) end |