1#!/usr/bin/env ruby 2 3# Validation program for the new YAML headers at the top of TeX hyphenation files. 4# Run on an individual file or a directory to get a report of all the errors on the terminal. 5# Copyright (c) 2016–2017 Arthur Reutenauer. MIT licence (https://opensource.org/licenses/MIT) 6 7# TODO Add the optional “source” top-level entry 8 9require 'yaml' 10require 'pp' 11 12class HeaderValidator 13 class WellFormednessError < StandardError # probably not an English word ;-) 14 end 15 16 class ValidationError < StandardError 17 end 18 19 class InternalError < StandardError 20 end 21 22 @@format = { 23 title: { 24 mandatory: true, 25 type: String, 26 }, 27 copyright: { 28 mandatory: true, 29 type: String, 30 }, 31 authors: { 32 mandatory: true, 33 type: Array, 34 }, 35 language: { 36 mandatory: true, 37 type: { 38 name: { 39 mandatory: true, 40 type: String, 41 }, 42 tag: { 43 mandatory: true, 44 type: String, 45 }, 46 }, 47 }, 48 version: { 49 mandatory: false, 50 type: String, 51 }, 52 notice: { 53 mandatory: true, 54 type: String, 55 }, 56 licence: { 57 mandatory: true, 58 one_or_more: true, 59 type: "[Knuth only knows]", # TODO Define 60 }, 61 changes: { 62 mandatory: false, 63 type: String, 64 }, 65 hyphenmins: { 66 mandatory: true, 67 type: { 68 generation: { 69 mandatory: false, # TODO Find way to describe that it *is* mandatory if typesetting absent 70 type: { 71 left: { 72 mandatory: true, 73 type: Integer, 74 }, 75 right: { 76 mandatory: true, 77 type: Integer, 78 }, 79 }, 80 }, 81 typesetting: { 82 mandatory: false, 83 type: { 84 left: { 85 mandatory: true, 86 type: Integer, 87 }, 88 right: { 89 mandatory: true, 90 type: Integer, 91 }, 92 }, 93 }, 94 }, 95 }, 96 texlive: { 97 mandatory: false, 98 type: { 99 synonyms: { 100 mandatory: false, 101 type: Array 102 }, 103 encoding: { 104 mandatory: false, 105 type: String 106 }, 107 message: { 108 mandatory: false, 109 type: String 110 }, 111 legacy_patterns: { 112 mandatory: false, 113 type: String 114 }, 115 use_old_loader: { 116 mandatory: false, 117 # type: Bool # FIXME 118 }, 119 use_old_patterns_comment: { 120 mandatory: false, 121 type: String 122 }, 123 description: { 124 mandatory: false, 125 type: String 126 }, 127 babelname: { 128 mandatory: false, 129 type: String 130 }, 131 package: { 132 mandatory: false, 133 type: String 134 } 135 }, 136 }, 137 source: { 138 mandatory: false, 139 type: String 140 }, 141 known_bugs: { 142 mandatory: false, 143 type: Array # of strings! 144 } 145 } 146 147 def initialize 148 @errors = { } 149 end 150 151 def parse(filename) 152 header = '' 153 eohmarker = '=' * 42 154 File.read(filename).each_line do |line| 155 if line =~ /\\patterns|#{eohmarker}/ 156 break 157 end 158 159 line.gsub!(/^% /, '') 160 line.gsub!(/%/, '') 161 header += line 162 end 163 164 begin 165 @metadata = YAML::load(header) 166 bcp47 = filename.gsub(/.*hyph-/, '').gsub(/\.tex$/, '') 167 raise ValidationError.new("Empty metadata set for language [#{bcp47}]") unless @metadata 168 rescue Psych::SyntaxError => err 169 raise WellFormednessError.new(err.message) 170 end 171 end 172 173 def check_mandatory(hash, validator) 174 validator.each do |key, validator| 175 if validator[:mandatory] 176 if !hash.include? key.to_s # Subtle difference between key not present and value is nil :-) 177 raise ValidationError.new("Key #{key} missing") # TODO Say where! 178 end 179 end 180 check_mandatory(hash[key.to_s], validator[:type]) if hash[key.to_s] && validator[:type].respond_to?(:keys) 181 end 182 end 183 184 def validate(hash, validator) 185 hash.each do |key, value| 186 raise ValidationError.new("Invalid key #{key} found") if validator[key.to_sym] == nil 187 raise ValidationError.new("P & S") if key == 'texlive' && hash['texlive']['package'] && hash['texlive']['description'] 188 validate(value, validator[key.to_sym][:type]) if value.respond_to?(:keys) && !validator[key.to_sym][:one_or_more] 189 end 190 end 191 192 def run!(pattfile) 193 unless File.file?(pattfile) 194 raise InternalError.new("Argument “#{pattfile}” is not a file; this shouldn’t have happened.") 195 end 196 parse(pattfile) 197 check_mandatory(@metadata, @@format) 198 validate(@metadata, @@format) 199 { title: @metadata['title'], copyright: @metadata['copyright'], notice: @metadata['notice'] } 200 end 201 202 def runfile(filename) 203 begin 204 run! filename 205 rescue InternalError, WellFormednessError, ValidationError => err 206 (@errors[filename] ||= []) << [err.class, err.message] 207 end 208 end 209 210 def main(args) 211 print 'Validating ' 212 # TODO Sort input file list in alphabetical order of names! 213 @mode = 'default' 214 arg = args.shift 215 if arg == '-m' # Mojca mode 216 @mode = 'mojca' 217 else 218 args = [arg] + args 219 end 220 @headings = [] 221 while !args.empty? 222 arg = args.shift 223 unless arg 224 puts "Please give me at least one file to validate." 225 exit -1 226 end 227 228 if File.file? arg 229 print 'file ', arg 230 @headings << [File.basename(arg), runfile(arg)] 231 elsif Dir.exists? arg 232 print 'files in ', arg, ': ' 233 print(Dir.foreach(arg).map do |filename| 234 next if filename == '.' || filename == '..' 235 f = File.join(arg, filename) 236 @headings << [filename, runfile(f)] unless Dir.exists? f 237 filename.gsub(/^hyph-/, '').gsub(/\.tex$/, '') 238 end.compact.sort.join ' ') 239 else 240 puts "Argument #{arg} is neither an existing file nor an existing directory; proceeding." unless @mode == 'mojca' 241 end 242 243 puts 244 end 245 246 if @mode == 'mojca' 247 @headings.sort! { |a, b| a.first <=> b.first } 248 [:title, :copyright, :notice].each do |heading| 249 puts heading.capitalize 250 puts @headings.map { |filedata| "#{filedata.first}: #{filedata.last[heading]}" }.join("\n") 251 puts "" 252 end 253 else 254 list = if Dir.exists?(arg) then arg else @headings.map(&:first).join ' ' end 255 puts "\nReport on #{list}:" 256 summary = [] 257 @errors.each do |filename, messages| 258 messages.each do |file| 259 classname = file.first 260 message = file.last 261 summary << "#{filename}: #{classname} #{message}" 262 end 263 end 264 265 if (exitcode = summary.count) > 0 266 puts "There were the following errors with some files:" 267 puts summary.join "\n" 268 exit exitcode 269 else 270 puts "No errors were found." 271 end 272 end 273 end 274end 275 276validator = HeaderValidator.new 277validator.main(ARGV) 278