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
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/]*)}- CHORD_START_PATTERN =
/\[(\^\^|\^|__|_|=)?[A-Ga-g]/- CHORD_NOTE_PATTERN =
%r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}- REST_PATTERN =
%r{z([\d/]*)}- VOLTA_DIGITS_PATTERN =
/\d[\d,-]*/- SNIPPET_LENGTH =
20- BEAMABLE_TOKEN_TYPES =
Music tokens whose whitespace successor breaks a beam group; other tokens (bar lines, ties, etc.) already interrupt beaming on their own.
[:note, :rest, :chord].freeze
Instance Method Summary collapse
-
#beam_break_token(scanner, line_number) ⇒ Object
private
-
#beamable_predecessor?(token) ⇒ Boolean
private
A beam break only matters after a music token; whitespace elsewhere (leading, after a bar line) carries no beaming signal.
-
#chord_fallback(scanner, start_pos, line_number, column, tokens) ⇒ Object
private
Non-note content inside the brackets (ties, rests, spaces, decorations) makes the whole group one unsupported token, matching how those constructs surface outside a chord.
-
#chord_snippet(scanner, start_pos) ⇒ Object
private
-
#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_chord(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_tie(scanner, line_number, column, tokens) ⇒ Object
private
A tie (a hyphen following a note or chord) joins it to the next note of the same pitch; the parser fuses the two into one sounding value.
-
#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, 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.
47 48 49 50 51 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 47 def initialize(body_text, start_line: 1) @body = body_text.to_s @start_line = start_line ensure_valid_encoding end |
Instance Method Details
#beam_break_token(scanner, line_number) ⇒ Object (private)
135 136 137 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 135 def beam_break_token(scanner, line_number) Token.new(type: :beam_break, line: line_number, column: scanner.pos + 1) end |
#beamable_predecessor?(token) ⇒ Boolean (private)
A beam break only matters after a music token; whitespace elsewhere (leading, after a bar line) carries no beaming signal.
131 132 133 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 131 def beamable_predecessor?(token) !token.nil? && BEAMABLE_TOKEN_TYPES.include?(token.type) end |
#chord_fallback(scanner, start_pos, line_number, column, tokens) ⇒ Object (private)
Non-note content inside the brackets (ties, rests, spaces, decorations) makes the whole group one unsupported token, matching how those constructs surface outside a chord.
225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 225 def chord_fallback(scanner, start_pos, line_number, column, tokens) if scanner.eos? raise HeadMusic::Notation::ABC::ParseError.new( 'Unterminated chord; expected "]"', line_number: line_number, snippet: chord_snippet(scanner, start_pos) ) end scanner.pos = start_pos lexeme = scanner.scan(/\[[^\]]*\]/) || scanner.scan(/\[[^\]]*/) tokens << unsupported_token(lexeme, line_number, column) true end |
#chord_snippet(scanner, start_pos) ⇒ Object (private)
238 239 240 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 238 def chord_snippet(scanner, start_pos) scanner.string[start_pos, SNIPPET_LENGTH] end |
#ensure_valid_encoding ⇒ Object (private)
59 60 61 62 63 64 65 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 59 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.
101 102 103 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 101 def header_field_line?(line_text) line_text.match?(/\A[A-Za-z]:/) && !["|", ":"].include?(line_text[2]) end |
#lex ⇒ Object (private)
67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 67 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.
84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 84 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)
326 327 328 329 330 331 332 333 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 326 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)
164 165 166 167 168 169 170 171 172 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 164 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)
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 184 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 return scan_chord(scanner, line_number, column, tokens) if scanner.check(CHORD_START_PATTERN) raise_unexpected_character(scanner, line_number, column) end |
#scan_broken_rhythm(scanner, line_number, column, tokens) ⇒ Object (private)
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 278 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_chord(scanner, line_number, column, tokens) ⇒ Object (private)
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 204 def scan_chord(scanner, line_number, column, tokens) start_pos = scanner.pos scanner.skip(/\[/) notes = [] until scanner.skip(/\]/) unless scanner.scan(CHORD_NOTE_PATTERN) return chord_fallback(scanner, start_pos, line_number, column, tokens) end notes << ChordNote.new( accidental: scanner[1], letter: scanner[2], octave_marks: scanner[3], length: scanner[4] ) end length = scanner.scan(%r{[\d/]*}) tokens << Token.new(type: :chord, line: line_number, column: column, notes: notes, length: length) true end |
#scan_line(line_text, line_number, tokens) ⇒ Object (private)
Returns true when the line ends with a continuation backslash.
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_line(line_text, line_number, tokens) scanner = StringScanner.new(line_text) until scanner.eos? spaced = scanner.skip(/[ \t]+/) break if scanner.skip(/%/) return true if scanner.skip(/\\[ \t]*\z/) break if scanner.eos? tokens << beam_break_token(scanner, line_number) if spaced && beamable_predecessor?(tokens.last) scan_token(scanner, line_number, tokens) end false end |
#scan_note(scanner, line_number, column, tokens) ⇒ Object (private)
260 261 262 263 264 265 266 267 268 269 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 260 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.
156 157 158 159 160 161 162 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 156 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)
271 272 273 274 275 276 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 271 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_tie(scanner, line_number, column, tokens) ⇒ Object (private)
A tie (a hyphen following a note or chord) joins it to the next note of the same pitch; the parser fuses the two into one sounding value. Ties inside a bracket chord still surface as unsupported via the chord fallback.
301 302 303 304 305 306 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 301 def scan_tie(scanner, line_number, column, tokens) return false unless scanner.scan("-") tokens << Token.new(type: :tie, line: line_number, column: column) true end |
#scan_token(scanner, line_number, tokens) ⇒ Object (private)
139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 139 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_tie(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).
176 177 178 179 180 181 182 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 176 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, and special rests.
310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 310 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
53 54 55 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 53 def tokens @tokens ||= lex end |
#unsupported_token(lexeme, line_number, column) ⇒ Object (private)
322 323 324 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 322 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)
105 106 107 108 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 105 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)
253 254 255 256 257 258 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 253 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)
242 243 244 245 246 247 248 249 250 251 |
# File 'lib/head_music/notation/abc/body_lexer.rb', line 242 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 |