5 Interactive Texinfo cross-references checking and fixing tool
21 warn_not_fixed = "*** Warning: this broken x-ref has not been fixed!\n"
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.
29 opt_parser.add_option ('-a', '--auto-fix',
30 help="Automatically fix cross-references whenever \
36 opt_parser.add_option ('-b', '--batch',
37 help="Do not run interactively",
42 opt_parser.add_option ('-p', '--check-punctuation',
43 help="Check for punctuation after x-ref",
45 dest='check_punctuation',
48 (options, files) = opt_parser.parse_args ()
50 class InteractionError (Exception):
57 def set_exit_code (n):
59 exit_code = max (exit_code, n)
61 if options.interactive:
67 def yes_prompt (question, default=False, retries=3):
68 d = {True: 'y', False: 'n'}.get (default, False)
70 a = raw_input ('%s [default: %s]' % (question, d) + '\n')
71 if a.lower ().startswith ('y'):
73 if a.lower ().startswith ('n'):
75 if a == '' or retries < 0:
77 stdout.write ("Please answer yes or no.\n")
81 """Prompt user for a substring to look for in node names.
83 If user input is empty or matches no node name, return None,
84 otherwise return a list of (manual, node name, file) tuples.
87 substring = raw_input ("Enter a substring to search in node names \
88 (press Enter to skip this x-ref):\n")
91 substring = substring.lower ()
94 matches += [(k, node, manuals[k]['nodes'][node][0])
95 for node in manuals[k]['nodes']
96 if substring in node.lower ()]
100 def yes_prompt (question, default=False, retries=3):
103 def search_prompt ():
106 ref_re = re.compile (r'@(ref|ruser|rlearning|rprogram|rglos)\{([^,\\]*?)\}(.)',
108 node_include_re = re.compile (r'(?m)^@(node|include)\s+(.+?)$')
110 whitespace_re = re.compile (r'\s+')
111 line_start_re = re.compile ('(?m)^')
113 manuals_to_refs = {'lilypond': 'ruser',
114 'lilypond-learning': 'rlearning',
115 'lilypond-program': 'rprogram',
116 # 'lilypond-snippets': 'rlsr',
117 'music-glossary': 'rglos'}
119 def which_line (index, newline_indices):
120 """Calculate line number of a given string index
122 Return line number of string index index, where
123 newline_indices is an ordered iterable of all newline indices.
126 sup = len (newline_indices) - 1
127 n = len (newline_indices)
128 while inf + 1 != sup:
130 if index >= newline_indices [m]:
136 def read_file (f, d):
137 """Look for all node names and cross-references in a Texinfo document
139 Return a dictionary with three keys:
141 'manual' contains the cross-reference
142 macro name which matches the base name of f in global manuals_to_refs
145 'nodes' is a dictionary of `node name':(file name, line number),
147 'contents' is a dictionary of file:`full file contents'.
149 'newline_indices' is a dictionary of
150 file:[list of beginning-of-line string indices]
152 Included files that can be read are processed too.
156 # TODO (maybe as option)
157 # s = trim_comments (s)
158 base = os.path.basename (f)
159 dir = os.path.dirname (f)
161 d['manual'] = manuals_to_refs.get (os.path.splitext (base)[0], '')
162 print "Processing manual %s(%s)" % (f, d['manual'])
165 d['newline_indices'] = {}
167 d['newline_indices'][f] = [m.end () for m in line_start_re.finditer (s)]
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)
175 elif m.group (1) == 'include':
176 p = os.path.join (dir, m.group (2))
177 if os.path.isfile (p):
180 p = os.path.join (dir, outdir, m.group (2))
181 if os.path.isfile (p):
185 log.write ("Reading files...\n")
187 manuals = dict ([(d['manual'], d)
188 for d in [read_file (f, dict ()) for f in files]])
193 def add_fix (old_type, old_ref, new_type, new_ref):
194 ref_fixes.add ((old_type, old_ref, new_type, new_ref))
198 for (old_type, old_ref, new_type, new_ref) in ref_fixes:
200 found.append ((new_type, new_ref))
204 def preserve_linebreak (text, linebroken):
207 text = text.replace (' ', '\n', 1)
215 def choose_in_numbered_list (message, string_list, sep=' ', retries=3):
216 S = set (string_list)
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'
224 stdout.write (message +
225 "(press Enter to discard and start a new search)\n")
226 input = raw_input (numbered_list)
230 value = string_list[int (input) - 1]
232 stdout.write ("Error: index number out of range\n")
234 matches = [input in v for v in string_list]
235 n = matches.count (True)
237 stdout.write ("Error: input matches no item in the list\n")
239 stdout.write ("Error: ambiguous input (matches several items \
242 value = string_list[matches.index (True)]
246 raise InteractionError ("%d retries limit exceeded" % retries)
248 def check_ref (manual, file, m):
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)
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))
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?"):
274 explicit_type = manual
276 if not name in manuals[explicit_type]['nodes']:
280 stdout.write ("%s: %d: `%s': wrong internal x-ref\n"
281 % (file, line, name))
283 stdout.write ("%s: %d: `%s': wrong external `%s' x-ref\n"
284 % (file, line, name, type))
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)]] +
292 # try to find the reference in other manuals
294 for k in [k for k in manuals if k != explicit_type]:
295 if name in manuals[k]['nodes']:
298 stdout.write (" found as internal x-ref\n")
302 stdout.write (" found as `%s' x-ref\n" % k)
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)
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)
318 add_fix (type, name, t, name)
323 # try to find a fix already made
324 found = lookup_fix (name)
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]
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]])
342 type, new_name = concatenated.split (' ', 1)
346 # all previous automatic fixes attempts failed,
347 # ask user for substring to look in node names
349 node_list = search_prompt ()
350 if node_list == None:
351 if options.interactive:
352 stdout.write (warn_not_fixed)
355 stdout.write ("No matched node names.\n")
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],
362 t, z = concatenated.split (' ', 1)
363 new_name = z.split (' (in ', 1)[0]
364 add_fix (type, name, t, new_name)
369 # compute returned string
371 return ('@%s{%s}' % (type, original_name)) + next_char
374 (ref, n) = preserve_linebreak (new_name, linebroken)
375 return ('@%s{%s}' % (type, ref)) + next_char + n
378 log.write ("Checking cross-references...\n")
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)
388 except KeyboardInterrupt:
389 log.write ("Operation interrupted, exiting.\n")
392 except InteractionError, instance:
393 log.write ("Operation refused by user: %s\nExiting.\n" % instance)
396 log.write ("Done, fixed %d x-refs.\n" % fixes_count)