| 1 | # vim: ts=4 expandtab |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2005 Jason Parks <jparks@jparks.net>. All rights reserved. |
|---|
| 4 | # |
|---|
| 5 | |
|---|
| 6 | from __future__ import generators |
|---|
| 7 | |
|---|
| 8 | import os |
|---|
| 9 | import time |
|---|
| 10 | import posixpath |
|---|
| 11 | import re |
|---|
| 12 | import mimetypes |
|---|
| 13 | |
|---|
| 14 | from trac.config import Option |
|---|
| 15 | from trac.core import * |
|---|
| 16 | from trac.web import IRequestHandler |
|---|
| 17 | from trac.perm import IPermissionRequestor |
|---|
| 18 | from trac.web.chrome import INavigationContributor, ITemplateProvider, \ |
|---|
| 19 | add_stylesheet |
|---|
| 20 | from trac.Search import ISearchSource |
|---|
| 21 | from trac.wiki import WikiSystem, IWikiSyntaxProvider |
|---|
| 22 | from trac.wiki.model import WikiPage |
|---|
| 23 | from trac.wiki.formatter import wiki_to_html |
|---|
| 24 | from trac.util.html import html |
|---|
| 25 | |
|---|
| 26 | def compare_rank(x, y): |
|---|
| 27 | if x['rank'] == y['rank']: |
|---|
| 28 | return 0 |
|---|
| 29 | elif x['rank'] > y['rank']: |
|---|
| 30 | return -1 |
|---|
| 31 | return 1 |
|---|
| 32 | |
|---|
| 33 | class DoxygenPlugin(Component): |
|---|
| 34 | implements(IPermissionRequestor, INavigationContributor, IRequestHandler, |
|---|
| 35 | ITemplateProvider, ISearchSource, IWikiSyntaxProvider) |
|---|
| 36 | |
|---|
| 37 | base_path = Option('doxygen', 'path', '/var/lib/trac/doxygen', |
|---|
| 38 | """Directory containing doxygen generated files.""") |
|---|
| 39 | |
|---|
| 40 | default_doc = Option('doxygen', 'default_documentation', '', |
|---|
| 41 | """Default documentation project, relative to `[doxygen] path`. |
|---|
| 42 | When no explicit path is given in a documentation request, |
|---|
| 43 | this path will be prepended to the request before looking |
|---|
| 44 | for documentation files.""") |
|---|
| 45 | |
|---|
| 46 | html_output = Option('doxygen', 'html_output', None, |
|---|
| 47 | """Default documentation project suffix, as generated by Doxygen |
|---|
| 48 | using the HTML_OUTPUT Doxygen configuration setting.""") |
|---|
| 49 | |
|---|
| 50 | title = Option('doxygen', 'title', 'Doxygen', |
|---|
| 51 | """Title to use for the main navigation tab.""") |
|---|
| 52 | |
|---|
| 53 | ext = Option('doxygen', 'ext', 'htm html png', |
|---|
| 54 | """Space separated list of extensions for doxygen managed files.""") |
|---|
| 55 | |
|---|
| 56 | source_ext = Option('doxygen', 'source_ext', |
|---|
| 57 | 'idl odl java cs py php php4 inc phtml m ' |
|---|
| 58 | 'cpp cxx c hpp hxx h', |
|---|
| 59 | """Space separated list of source files extensions""") |
|---|
| 60 | |
|---|
| 61 | index = Option('doxygen', 'index', 'main.html', |
|---|
| 62 | """Default index page to pick in the generated documentation.""") |
|---|
| 63 | |
|---|
| 64 | wiki_index = Option('doxygen', 'wiki_index', None, |
|---|
| 65 | """Wiki page to use as the default page for the Doxygen main page. |
|---|
| 66 | If set, supersedes the `[doxygen] index` option.""") |
|---|
| 67 | |
|---|
| 68 | encoding = Option('doxygen', 'encoding', 'iso-8859-1', |
|---|
| 69 | """Default encoding used by the generated documentation files.""") |
|---|
| 70 | |
|---|
| 71 | SUMMARY_PAGES = """ |
|---|
| 72 | annotated classes dirs files functions globals hierarchy |
|---|
| 73 | index inherits main namespaces namespacemembers |
|---|
| 74 | """.split() |
|---|
| 75 | |
|---|
| 76 | # IPermissionRequestor methods |
|---|
| 77 | |
|---|
| 78 | def get_permission_actions(self): |
|---|
| 79 | return ['DOXYGEN_VIEW'] |
|---|
| 80 | |
|---|
| 81 | # INavigationContributor methods |
|---|
| 82 | |
|---|
| 83 | def get_active_navigation_item(self, req): |
|---|
| 84 | return 'doxygen' |
|---|
| 85 | |
|---|
| 86 | def get_navigation_items(self, req): |
|---|
| 87 | if req.perm.has_permission('DOXYGEN_VIEW'): |
|---|
| 88 | # Return mainnav buttons. |
|---|
| 89 | yield 'mainnav', 'doxygen', html.a(self.title, |
|---|
| 90 | href=req.href.doxygen()) |
|---|
| 91 | |
|---|
| 92 | # IRequestHandler methods |
|---|
| 93 | |
|---|
| 94 | def match_request(self, req): |
|---|
| 95 | if re.match(r'^/doxygen(?:$|/)', req.path_info): |
|---|
| 96 | if 'path' not in req.args: # not coming from a `doxygen:` link |
|---|
| 97 | segments = filter(None, req.path_info.split('/')) |
|---|
| 98 | segments = segments[1:] # ditch 'doxygen' |
|---|
| 99 | if segments: |
|---|
| 100 | action, path, link = self._doxygen_lookup(segments) |
|---|
| 101 | if action == 'search' and link: |
|---|
| 102 | req.args['query'] = link |
|---|
| 103 | elif action == 'redirect': |
|---|
| 104 | req.args['link'] = link |
|---|
| 105 | else: |
|---|
| 106 | action, path = 'index', '' |
|---|
| 107 | req.args['action'] = action |
|---|
| 108 | req.args['path'] = path |
|---|
| 109 | return True |
|---|
| 110 | |
|---|
| 111 | def process_request(self, req): |
|---|
| 112 | req.perm.assert_permission('DOXYGEN_VIEW') |
|---|
| 113 | |
|---|
| 114 | # Get request arguments |
|---|
| 115 | path = req.args.get('path') |
|---|
| 116 | action = req.args.get('action') |
|---|
| 117 | link = req.args.get('link') |
|---|
| 118 | |
|---|
| 119 | self.log.debug('Performing %s(%s,%s)"' % (action or 'default', |
|---|
| 120 | path, link)) |
|---|
| 121 | |
|---|
| 122 | # Redirect search requests. |
|---|
| 123 | if action == 'search': |
|---|
| 124 | req.redirect(req.href.search(q=req.args.get('query'), |
|---|
| 125 | doxygen='on')) |
|---|
| 126 | if action == 'redirect': |
|---|
| 127 | if link: # we need to really redirect if there is a link |
|---|
| 128 | if path: |
|---|
| 129 | req.redirect(req.href.doxygen(path=path)+link) |
|---|
| 130 | else: |
|---|
| 131 | req.redirect(req.href.doxygen(link)) |
|---|
| 132 | else: |
|---|
| 133 | self.log.warn("redirect without link") |
|---|
| 134 | |
|---|
| 135 | if req.path_info == '/doxygen': |
|---|
| 136 | req.redirect(req.href.doxygen('/')) |
|---|
| 137 | |
|---|
| 138 | # Handle /doxygen request |
|---|
| 139 | if action == 'index': |
|---|
| 140 | wiki = self.wiki_index |
|---|
| 141 | if wiki: |
|---|
| 142 | if WikiSystem(self.env).has_page(wiki): |
|---|
| 143 | text = WikiPage(self.env, wiki).text |
|---|
| 144 | else: |
|---|
| 145 | text = 'Doxygen index page [wiki:%s] does not exist.' % \ |
|---|
| 146 | wiki |
|---|
| 147 | text = wiki_to_html(text, self.env, req) |
|---|
| 148 | req.hdf['doxygen.text'] = text |
|---|
| 149 | req.hdf['doxygen.wiki_href'] = req.href.wiki(wiki) |
|---|
| 150 | req.hdf['doxygen.wiki_page'] = wiki |
|---|
| 151 | return 'doxygen.cs', 'text/html' |
|---|
| 152 | |
|---|
| 153 | # use configured Doxygen index |
|---|
| 154 | path = os.path.join(self.base_path, self.default_doc, |
|---|
| 155 | self.html_output, self.index) |
|---|
| 156 | |
|---|
| 157 | self.log.debug('path: %s' % (path,)) |
|---|
| 158 | |
|---|
| 159 | # security check |
|---|
| 160 | path = os.path.abspath(path) |
|---|
| 161 | if not path.startswith(self.base_path): |
|---|
| 162 | raise TracError("Can't access paths outside of " + self.base_path) |
|---|
| 163 | |
|---|
| 164 | # view |
|---|
| 165 | mimetype = mimetypes.guess_type(path)[0] |
|---|
| 166 | if mimetype == 'text/html': |
|---|
| 167 | add_stylesheet(req, 'doxygen/css/doxygen.css') |
|---|
| 168 | req.hdf['doxygen.path'] = path |
|---|
| 169 | return 'doxygen.cs', 'text/html' |
|---|
| 170 | else: |
|---|
| 171 | req.send_file(path, mimetype) |
|---|
| 172 | |
|---|
| 173 | # ITemplateProvider methods |
|---|
| 174 | |
|---|
| 175 | def get_htdocs_dirs(self): |
|---|
| 176 | from pkg_resources import resource_filename |
|---|
| 177 | return [('doxygen', resource_filename(__name__, 'htdocs'))] |
|---|
| 178 | |
|---|
| 179 | def get_templates_dirs(self): |
|---|
| 180 | from pkg_resources import resource_filename |
|---|
| 181 | return [resource_filename(__name__, 'templates')] |
|---|
| 182 | |
|---|
| 183 | # ISearchProvider methods |
|---|
| 184 | |
|---|
| 185 | def get_search_filters(self, req): |
|---|
| 186 | if req.perm.has_permission('DOXYGEN_VIEW'): |
|---|
| 187 | yield('doxygen', self.title) |
|---|
| 188 | |
|---|
| 189 | def get_search_results(self, req, keywords, filters): |
|---|
| 190 | if not 'doxygen' in filters: |
|---|
| 191 | return |
|---|
| 192 | |
|---|
| 193 | # We have to search for the raw bytes... |
|---|
| 194 | keywords = [k.encode(self.encoding) for k in keywords] |
|---|
| 195 | |
|---|
| 196 | for doc in os.listdir(self.base_path): |
|---|
| 197 | # Search in documentation directories |
|---|
| 198 | path = os.path.join(self.base_path, doc) |
|---|
| 199 | path = os.path.join(path, self.html_output) |
|---|
| 200 | if os.path.isdir(path): |
|---|
| 201 | index = os.path.join(path, 'search.idx') |
|---|
| 202 | if os.path.exists(index): |
|---|
| 203 | creation = os.path.getctime(index) |
|---|
| 204 | for result in self._search_in_documentation(doc, keywords): |
|---|
| 205 | result['url'] = req.href.doxygen(doc) + '/' \ |
|---|
| 206 | + result['url'] |
|---|
| 207 | yield result['url'], result['name'], creation, \ |
|---|
| 208 | 'doxygen', None |
|---|
| 209 | |
|---|
| 210 | # Search in common documentation directory |
|---|
| 211 | index = os.path.join(self.base_path, self.html_output) |
|---|
| 212 | index = os.path.join(index, 'search.idx') |
|---|
| 213 | if os.path.exists(index): |
|---|
| 214 | creation = os.path.getctime(index) |
|---|
| 215 | for result in self._search_in_documentation('', keywords): |
|---|
| 216 | result['url'] = req.href.doxygen() + '/' + \ |
|---|
| 217 | result['url'] |
|---|
| 218 | yield result['url'], result['name'], creation, 'doxygen', \ |
|---|
| 219 | None |
|---|
| 220 | |
|---|
| 221 | # IWikiSyntaxProvider |
|---|
| 222 | |
|---|
| 223 | def get_link_resolvers(self): |
|---|
| 224 | def doxygen_link(formatter, ns, params, label): |
|---|
| 225 | if '/' not in params: |
|---|
| 226 | params = self.default_doc+'/'+params |
|---|
| 227 | segments = params.split('/') |
|---|
| 228 | if self.html_output: |
|---|
| 229 | segments[-1:-1] = [self.html_output] |
|---|
| 230 | action, path, link = self._doxygen_lookup(segments) |
|---|
| 231 | if action == 'index': |
|---|
| 232 | return html.a(label, title=self.title, |
|---|
| 233 | href=formatter.href.doxygen()) |
|---|
| 234 | if action == 'redirect' and path: |
|---|
| 235 | return html.a(label, title="Search result for "+params, |
|---|
| 236 | href=formatter.href.doxygen(link,path=path)) |
|---|
| 237 | if action == 'search': |
|---|
| 238 | return html.a(label, title=params, class_='missing', |
|---|
| 239 | href=formatter.href.doxygen()) |
|---|
| 240 | else: |
|---|
| 241 | return html.a(label, title=params, |
|---|
| 242 | href=formatter.href.doxygen(link, path=path)) |
|---|
| 243 | yield ('doxygen', doxygen_link) |
|---|
| 244 | |
|---|
| 245 | def get_wiki_syntax(self): |
|---|
| 246 | return [] |
|---|
| 247 | |
|---|
| 248 | # internal methods |
|---|
| 249 | |
|---|
| 250 | def _doxygen_lookup(self, segments): |
|---|
| 251 | """Try to interpret path components as a request for doxygen targets |
|---|
| 252 | |
|---|
| 253 | Return an `(action,path,link)` tuple, where: |
|---|
| 254 | - `action` describes what should be done (one of 'view', |
|---|
| 255 | 'redirect', or 'search'), |
|---|
| 256 | - `path` is the location on disk of the resource. |
|---|
| 257 | - `link` is the link to the resource, relative to the |
|---|
| 258 | req.href.doxygen base or a target in case of 'redirect' |
|---|
| 259 | """ |
|---|
| 260 | doc, file = segments[:-1], segments and segments[-1] |
|---|
| 261 | |
|---|
| 262 | if not doc and not file: |
|---|
| 263 | return ('index', None, None) |
|---|
| 264 | if doc: |
|---|
| 265 | doc = os.path.join(*doc) |
|---|
| 266 | else: |
|---|
| 267 | if self.default_doc: # we can't stay at the 'doxygen/' level |
|---|
| 268 | return 'redirect', None, '/'.join([self.default_doc, |
|---|
| 269 | self.html_output, |
|---|
| 270 | file or self.index]) |
|---|
| 271 | else: |
|---|
| 272 | doc = self.html_output |
|---|
| 273 | |
|---|
| 274 | def lookup(file, category='undefined'): |
|---|
| 275 | """Build (full path, relative link) and check if path exists.""" |
|---|
| 276 | path = os.path.join(self.base_path, doc, file) |
|---|
| 277 | existing_path = os.path.exists(path) and path |
|---|
| 278 | link = doc+'/'+file |
|---|
| 279 | self.log.debug(' %s file %s' % (category, existing_path or |
|---|
| 280 | path+" (not found)")) |
|---|
| 281 | return existing_path, link |
|---|
| 282 | |
|---|
| 283 | self.log.debug('Looking up "%s" in documentation "%s"' % (file, doc)) |
|---|
| 284 | |
|---|
| 285 | # Direct request for searching |
|---|
| 286 | if file == 'search.php': |
|---|
| 287 | return 'search', None, None # keep existing 'query' arg |
|---|
| 288 | |
|---|
| 289 | # Request for a documentation file. |
|---|
| 290 | doc_ext_re = '|'.join(self.ext.split(' ')) |
|---|
| 291 | if re.match(r'''^(.*)[.](%s)''' % doc_ext_re, file): |
|---|
| 292 | path, link = lookup(file, 'documentation') |
|---|
| 293 | if path: |
|---|
| 294 | return 'view', path, link |
|---|
| 295 | else: |
|---|
| 296 | return 'search', None, file |
|---|
| 297 | |
|---|
| 298 | # Request for source file documentation. |
|---|
| 299 | source_ext_re = '|'.join(self.source_ext.split(' ')) |
|---|
| 300 | match = re.match(r'''^(.*)[.](%s)''' % source_ext_re, file) |
|---|
| 301 | if match: |
|---|
| 302 | basename, suffix = match.groups() |
|---|
| 303 | basename = basename.replace('_', '__') |
|---|
| 304 | path, link = lookup('%s_8%s.html' % (basename, suffix), 'source') |
|---|
| 305 | if path: |
|---|
| 306 | return 'view', path, link |
|---|
| 307 | else: |
|---|
| 308 | return 'search', None, file |
|---|
| 309 | |
|---|
| 310 | # Request for summary pages |
|---|
| 311 | if file in self.SUMMARY_PAGES: |
|---|
| 312 | path, link = lookup(file + '.html', 'summary') |
|---|
| 313 | if path: |
|---|
| 314 | return 'view', path, link |
|---|
| 315 | |
|---|
| 316 | # Request for a named object |
|---|
| 317 | # TODO: |
|---|
| 318 | # - do something about dirs |
|---|
| 319 | # - expand with enum, defs, etc. |
|---|
| 320 | # - this doesn't work well with the CREATE_SUBDIRS Doxygen option |
|---|
| 321 | path, link = lookup('class%s.html' % file, 'class') |
|---|
| 322 | if not path: |
|---|
| 323 | path, link = lookup('struct%s.html' % file, 'struct') |
|---|
| 324 | if path: |
|---|
| 325 | return 'view', path, link |
|---|
| 326 | |
|---|
| 327 | # Revert to search... |
|---|
| 328 | results = self._search_in_documentation(doc, [file]) |
|---|
| 329 | class_ref = file+' Class Reference' |
|---|
| 330 | for result in results: |
|---|
| 331 | self.log.debug('Reverted to search, found: ' + repr(result)) |
|---|
| 332 | name = result['name'] |
|---|
| 333 | if name == file or name == class_ref: |
|---|
| 334 | url = result['url'] |
|---|
| 335 | target = '' |
|---|
| 336 | if '#' in url: |
|---|
| 337 | url, target = url.split('#', 2) |
|---|
| 338 | path, link = lookup(url) |
|---|
| 339 | if path: |
|---|
| 340 | return 'redirect', path, link # target # FIXME |
|---|
| 341 | self.log.debug('%s not found in %s' % (file, doc)) |
|---|
| 342 | return 'search', None, file |
|---|
| 343 | |
|---|
| 344 | def _search_in_documentation(self, doc, keywords): |
|---|
| 345 | # Open index file for documentation |
|---|
| 346 | index = os.path.join(self.base_path, doc, self.html_output, 'search.idx') |
|---|
| 347 | if os.path.exists(index): |
|---|
| 348 | fd = open(index) |
|---|
| 349 | |
|---|
| 350 | # Search for keywords in index |
|---|
| 351 | results = [] |
|---|
| 352 | for keyword in keywords: |
|---|
| 353 | results += self._search(fd, keyword) |
|---|
| 354 | results.sort(compare_rank) |
|---|
| 355 | for result in results: |
|---|
| 356 | yield result |
|---|
| 357 | |
|---|
| 358 | def _search(self, fd, word): |
|---|
| 359 | results = [] |
|---|
| 360 | index = self._computeIndex(word) |
|---|
| 361 | if index != -1: |
|---|
| 362 | fd.seek(index * 4 + 4, 0) |
|---|
| 363 | index = self._readInt(fd) |
|---|
| 364 | |
|---|
| 365 | if index: |
|---|
| 366 | fd.seek(index) |
|---|
| 367 | w = self._readString(fd) |
|---|
| 368 | matches = [] |
|---|
| 369 | while w != "": |
|---|
| 370 | statIdx = self._readInt(fd) |
|---|
| 371 | low = word.lower() |
|---|
| 372 | if w.find(low) != -1: |
|---|
| 373 | matches.append({'word': word, 'match': w, |
|---|
| 374 | 'index': statIdx, 'full': len(low) == len(w)}) |
|---|
| 375 | w = self._readString(fd) |
|---|
| 376 | |
|---|
| 377 | count = 0 |
|---|
| 378 | totalHi = 0 |
|---|
| 379 | totalFreqHi = 0 |
|---|
| 380 | totalFreqLo = 0 |
|---|
| 381 | |
|---|
| 382 | for match in matches: |
|---|
| 383 | multiplier = 1 |
|---|
| 384 | if match['full']: |
|---|
| 385 | multiplier = 2 |
|---|
| 386 | |
|---|
| 387 | fd.seek(match['index']) |
|---|
| 388 | numDocs = self._readInt(fd) |
|---|
| 389 | |
|---|
| 390 | for i in range(numDocs): |
|---|
| 391 | idx = self._readInt(fd) |
|---|
| 392 | if idx == -1: |
|---|
| 393 | freq = 0 |
|---|
| 394 | else: |
|---|
| 395 | freq = self._readInt(fd) |
|---|
| 396 | results.append({'idx': idx, 'freq': freq >> 1, |
|---|
| 397 | 'hi': freq & 1, 'multi': multiplier}) |
|---|
| 398 | if freq & 1: |
|---|
| 399 | totalHi += 1 |
|---|
| 400 | totalFreqHi += freq * multiplier |
|---|
| 401 | else: |
|---|
| 402 | totalFreqLo += freq * multiplier |
|---|
| 403 | |
|---|
| 404 | for i in range(numDocs): |
|---|
| 405 | if results[count]['idx'] == -1: |
|---|
| 406 | results[count]['name'] = '' |
|---|
| 407 | results[count]['url'] = '' |
|---|
| 408 | count += 1 |
|---|
| 409 | continue |
|---|
| 410 | fd.seek(results[count]['idx']) |
|---|
| 411 | name = self._readString(fd) |
|---|
| 412 | url = self._readString(fd) |
|---|
| 413 | results[count]['name'] = name |
|---|
| 414 | results[count]['url'] = self.html_output + '/' + url |
|---|
| 415 | count += 1 |
|---|
| 416 | |
|---|
| 417 | totalFreq = (totalHi + 1) * totalFreqLo + totalFreqHi |
|---|
| 418 | for i in range(count): |
|---|
| 419 | freq = results[i]['freq'] |
|---|
| 420 | multi = results[i]['multi'] |
|---|
| 421 | if results[i]['hi']: |
|---|
| 422 | results[i]['rank'] = float(freq*multi + totalFreqLo) \ |
|---|
| 423 | / float(totalFreq) |
|---|
| 424 | else: |
|---|
| 425 | results[i]['rank'] = float(freq*multi) \ |
|---|
| 426 | / float(totalFreq) |
|---|
| 427 | return results |
|---|
| 428 | |
|---|
| 429 | def _computeIndex(self, word): |
|---|
| 430 | if len(word) < 2: |
|---|
| 431 | return -1 |
|---|
| 432 | |
|---|
| 433 | hi = ord(word[0].lower()) |
|---|
| 434 | if hi == 0: |
|---|
| 435 | return -1 |
|---|
| 436 | |
|---|
| 437 | lo = ord(word[1].lower()) |
|---|
| 438 | if lo == 0: |
|---|
| 439 | return -1 |
|---|
| 440 | |
|---|
| 441 | return hi * 256 + lo |
|---|
| 442 | |
|---|
| 443 | def _readInt(self, fd): |
|---|
| 444 | b1 = fd.read(1) |
|---|
| 445 | b2 = fd.read(1) |
|---|
| 446 | b3 = fd.read(1) |
|---|
| 447 | b4 = fd.read(1) |
|---|
| 448 | |
|---|
| 449 | if not b1 or not b2 or not b3 or not b4: |
|---|
| 450 | return -1; |
|---|
| 451 | |
|---|
| 452 | return (ord(b1) << 24) | (ord(b2) << 16) | (ord(b3) << 8) | ord(b4) |
|---|
| 453 | |
|---|
| 454 | def _readString(self, fd): |
|---|
| 455 | result = '' |
|---|
| 456 | byte = fd.read(1) |
|---|
| 457 | while byte != '\0': |
|---|
| 458 | result = ''.join([result, byte]) |
|---|
| 459 | byte = fd.read(1) |
|---|
| 460 | return result |
|---|