]> git.donarmstrong.com Git - lilypond.git/blob - buildscripts/check_texi_refs.py
Merge branch 'lilypond/translation' of ssh://trettig@git.sv.gnu.org/srv/git/lilypond...
[lilypond.git] / buildscripts / check_texi_refs.py
1 #!/usr/bin/env python
2
3 """
4 check_texi_refs.py
5 Interactive Texinfo cross-references checking and fixing tool
6
7
8 """
9
10
11 import sys
12 import re
13 import os
14 import optparse
15
16 outdir = 'out-www'
17
18 log = sys.stderr
19 stdout = sys.stdout
20
21 warn_not_fixed = "*** Warning: this broken x-ref has not been fixed!\n"
22
23 opt_parser = optparse.OptionParser (usage='check_texi_refs.py [OPTION]... FILE',
24                                     description='''Check and fix \
25 cross-references in a collection of Texinfo
26 documents heavily cross-referenced each other.
27 ''')
28
29 opt_parser.add_option ('-a', '--auto-fix',
30                        help="Automatically fix cross-references whenever \
31 it is possible",
32                        action='store_true',
33                        dest='auto_fix',
34                        default=False)
35
36 opt_parser.add_option ('-b', '--batch',
37                        help="Do not run interactively",
38                        action='store_false',
39                        dest='interactive',
40                        default=True)
41
42 opt_parser.add_option ('-p', '--check-punctuation',
43                        help="Check for punctuation after x-ref",
44                        action='store_true',
45                        dest='check_punctuation',
46                        default=False)
47
48 (options, files) = opt_parser.parse_args ()
49
50 class InteractionError (Exception):
51     pass
52
53
54 manuals = {}
55 exit_code = 0
56
57 def set_exit_code (n):
58     global exit_code
59     exit_code = max (exit_code, n)
60
61 if options.interactive:
62     try:
63         import readline
64     except:
65         pass
66
67     def yes_prompt (question, default=False, retries=3):
68         d = {True: 'y', False: 'n'}.get (default, False)
69         while retries:
70             a = raw_input ('%s [default: %s]' % (question, d) + '\n')
71             if a.lower ().startswith ('y'):
72                 return True
73             if a.lower ().startswith ('n'):
74                 return False
75             if a == '' or retries < 0:
76                 return default
77             stdout.write ("Please answer yes or no.\n")
78             retries -= 1
79
80     def search_prompt ():
81         """Prompt user for a substring to look for in node names.
82
83 If user input is empty or matches no node name, return None,
84 otherwise return a list of (manual, node name, file) tuples.
85
86 """
87         substring = raw_input ("Enter a substring to search in node names \
88 (press Enter to skip this x-ref):\n")
89         if not substring:
90             return None
91         substring = substring.lower ()
92         matches = []
93         for k in manuals:
94             matches += [(k, node, manuals[k]['nodes'][node][0])
95                         for node in manuals[k]['nodes']
96                         if substring in node.lower ()]
97         return matches
98
99 else:
100     def yes_prompt (question, default=False, retries=3):
101         return default
102
103     def search_prompt ():
104         return None
105
106 ref_re = re.compile (r'@(ref|ruser|rlearning|rprogram|rglos)\{([^,\\]*?)\}(.)',
107                      re.DOTALL)
108 node_include_re = re.compile (r'(?m)^@(node|include)\s+(.+?)$')
109
110 whitespace_re = re.compile (r'\s+')
111 line_start_re = re.compile ('(?m)^')
112
113 manuals_to_refs = {'lilypond': 'ruser',
114                    'lilypond-learning': 'rlearning',
115                    'lilypond-program': 'rprogram',
116                    # 'lilypond-snippets': 'rlsr',
117                    'music-glossary': 'rglos'}
118
119 def which_line (index, newline_indices):
120     """Calculate line number of a given string index
121
122 Return line number of string index index, where
123 newline_indices is an ordered iterable of all newline indices.
124 """
125     inf = 0
126     sup = len (newline_indices) - 1
127     n = len (newline_indices)
128     while inf + 1 != sup:
129         m = (inf + sup) / 2
130         if index >= newline_indices [m]:
131             inf = m
132         else:
133             sup = m
134     return inf + 1
135
136 def read_file (f, d):
137     """Look for all node names and cross-references in a Texinfo document
138
139 Return a dictionary with three keys:
140
141   'manual' contains the cross-reference
142 macro name which matches the base name of f in global manuals_to_refs
143 dictionary,
144
145   'nodes' is a dictionary of `node name':(file name, line number),
146
147   'contents' is a dictionary of file:`full file contents'.
148
149   'newline_indices' is a dictionary of
150 file:[list of beginning-of-line string indices]
151
152 Included files that can be read are processed too.
153
154 """
155     s = open (f).read ()
156     # TODO (maybe as option)
157     # s = trim_comments (s)
158     base = os.path.basename (f)
159     dir = os.path.dirname (f)
160     if d == {}:
161         d['manual'] = manuals_to_refs.get (os.path.splitext (base)[0], '')
162         print "Processing manual %s(%s)" % (f, d['manual'])
163         d['nodes'] = {}
164         d['contents'] = {}
165         d['newline_indices'] = {}
166
167     d['newline_indices'][f] = [m.end () for m in line_start_re.finditer (s)]
168     d['contents'][f] = s
169
170     for m in node_include_re.finditer (s):
171         if m.group (1) == 'node':
172             line = which_line (m.start (), d['newline_indices'][f])
173             d['nodes'][m.group (2)] = (f, line)
174
175         elif m.group (1) == 'include':
176             p = os.path.join (dir, m.group (2))
177             if os.path.isfile (p):
178                 read_file (p, d)
179             else:
180                 p = os.path.join (dir, outdir, m.group (2))
181                 if os.path.isfile (p):
182                     read_file (p, d)
183     return d
184
185 log.write ("Reading files...\n")
186
187 manuals = dict ([(d['manual'], d)
188                  for d in [read_file (f, dict ()) for f in files]])
189
190 ref_fixes = set ()
191 fixes_count = 0
192
193 def add_fix (old_type, old_ref, new_type, new_ref):
194     ref_fixes.add ((old_type, old_ref, new_type, new_ref))
195
196 def lookup_fix (r):
197     found = []
198     for (old_type, old_ref, new_type, new_ref) in ref_fixes:
199         if r == old_ref:
200             found.append ((new_type, new_ref))
201     return found
202
203
204 def preserve_linebreak (text, linebroken):
205     if linebroken:
206         if ' ' in text:
207             text = text.replace (' ', '\n', 1)
208             n = ''
209         else:
210             n = '\n'
211     else:
212         n = ''
213     return (text, n)
214
215 def choose_in_numbered_list (message, string_list, sep=' ', retries=3):
216     S = set (string_list)
217     S.discard ('')
218     string_list = list (S)
219     numbered_list = sep.join ([str (j + 1) + '. ' + string_list[j]
220                                for j in range (len (string_list))]) + '\n'
221     t = retries
222     while t > 0:
223         value = ''
224         stdout.write (message +
225                       "(press Enter to discard and start a new search)\n")
226         input = raw_input (numbered_list)
227         if not input:
228             return ''
229         try:
230             value = string_list[int (input) - 1]
231         except IndexError:
232             stdout.write ("Error: index number out of range\n")
233         except ValueError:
234             matches = [input in v for v in string_list]
235             n = matches.count (True)
236             if n == 0:
237                 stdout.write ("Error: input matches no item in the list\n")
238             elif n > 1:
239                 stdout.write ("Error: ambiguous input (matches several items \
240 in the list)\n")
241             else:
242                 value = string_list[matches.index (True)]
243         if value:
244             return value
245         t -= 1
246     raise InteractionError ("%d retries limit exceeded" % retries)
247
248 def check_ref (manual, file, m):
249     global fixes_count
250     type = m.group (1)
251     original_name = m.group (2)
252     name = whitespace_re.sub (' ', original_name). strip ()
253     newline_indices = manuals[manual]['newline_indices'][file]
254     line = which_line (m.start (), newline_indices)
255     linebroken = '\n' in m.group (2)
256     next_char = m.group (3)
257
258     # check for puncuation after x-ref
259     if options.check_punctuation and not next_char in '.,;:!?':
260         stdout.write ("Warning: %s: %d: `%s': x-ref not followed by punctuation\n"
261                       % (file, line, name))
262
263     # validate xref
264     explicit_type = type
265     new_name = name
266
267     if type != 'ref' and type == manual:
268         stdout.write ("\n%s: %d: `%s': external %s x-ref should be internal\n"
269                       % (file, line, name, type))
270         if options.auto_fix or yes_prompt ("Fix this?"):
271             type = 'ref'
272
273     if type == 'ref':
274         explicit_type = manual
275
276     if not name in manuals[explicit_type]['nodes']:
277         fixed = False
278         stdout.write ('\n')
279         if type == 'ref':
280             stdout.write ("%s: %d: `%s': wrong internal x-ref\n"
281                           % (file, line, name))
282         else:
283             stdout.write ("%s: %d: `%s': wrong external `%s' x-ref\n"
284                           % (file, line, name, type))
285         # print context
286         stdout.write ('--\n' + manuals[manual]['contents'][file]
287                       [newline_indices[max (0, line - 2)]:
288                        newline_indices[min (line + 3,
289                                             len (newline_indices) - 1)]] +
290                       '--\n')
291
292         # try to find the reference in other manuals
293         found = []
294         for k in [k for k in manuals if k != explicit_type]:
295             if name in manuals[k]['nodes']:
296                 if k == manual:
297                     found = ['ref']
298                     stdout.write ("  found as internal x-ref\n")
299                     break
300                 else:
301                     found.append (k)
302                     stdout.write ("  found as `%s' x-ref\n" % k)
303
304         if len (found) == 1 and (options.auto_fix
305                                  or yes_prompt ("Fix this x-ref?")):
306             add_fix (type, name, found[0], name)
307             type = found[0]
308             fixed = True
309
310         elif len (found) > 1:
311             if options.interactive or options.auto_fix:
312                 stdout.write ("* Several manuals contain this node name, \
313 cannot determine manual automatically.\n")
314             if options.interactive:
315                 t = choose_in_numbered_list ("Choose manual for this x-ref by \
316 index number or beginning of name:\n", found)
317                 if t:
318                     add_fix (type, name, t, name)
319                     type = t
320                     fixed = True
321
322         if not fixed:
323             # try to find a fix already made
324             found = lookup_fix (name)
325
326             if len (found) == 1:
327                 stdout.write ("Found one previous fix: %s `%s'\n" % found[0])
328                 if options.auto_fix or yes_prompt ("Apply this fix?"):
329                     type, new_name = found[0]
330                     fixed = True
331
332             elif len (found) > 1:
333                 if options.interactive or options.auto_fix:
334                     stdout.write ("* Several previous fixes match \
335 this node name, cannot fix automatically.\n")
336                 if options.interactive:
337                     concatened = choose_in_numbered_list ("Choose new manual \
338 and x-ref by index number or beginning of name:\n", [''.join ([i[0], ' ', i[1]])
339                                                      for i in found],
340                                                     sep='\n')
341                     if concatened:
342                         type, new_name = concatenated.split (' ', 1)
343                         fixed = True
344
345         if not fixed:
346             # all previous automatic fixes attempts failed,
347             # ask user for substring to look in node names
348             while True:
349                 node_list = search_prompt ()
350                 if node_list == None:
351                     if options.interactive:
352                         stdout.write (warn_not_fixed)
353                     break
354                 elif not node_list:
355                     stdout.write ("No matched node names.\n")
356                 else:
357                     concatenated = choose_in_numbered_list ("Choose \
358 node name and manual for this x-ref by index number or beginning of name:\n", \
359                             [' '.join ([i[0], i[1], '(in %s)' % i[2]]) for i in node_list],
360                                                             sep='\n')
361                     if concatenated:
362                         t, z = concatenated.split (' ', 1)
363                         new_name = z.split (' (in ', 1)[0]
364                         add_fix (type, name, t, new_name)
365                         type = t
366                         fixed = True
367                         break
368
369     # compute returned string
370     if new_name == name:
371         return ('@%s{%s}' % (type, original_name)) + next_char
372     else:
373         fixes_count += 1
374         (ref, n) = preserve_linebreak (new_name, linebroken)
375         return ('@%s{%s}' % (type, ref)) + next_char + n
376
377
378 log.write ("Checking cross-references...\n")
379
380 try:
381     for key in manuals:
382         for file in manuals[key]['contents']:
383             s = ref_re.sub (lambda m: check_ref (key, file, m),
384                             manuals[key]['contents'][file])
385             if s != manuals[key]['contents'][file]:
386                 open (file, 'w').write (s)
387
388 except KeyboardInterrupt:
389     log.write ("Operation interrupted, exiting.\n")
390     sys.exit (2)
391
392 except InteractionError, instance:
393     log.write ("Operation refused by user: %s\nExiting.\n" % instance)
394     sys.exit (3)
395
396 log.write ("Done, fixed %d x-refs.\n" % fixes_count)