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