1#!/usr/bin/env python3
2#
3# Copyright 2001 Google Inc. All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Simple web server for browsing dependency graph data.
18
19This script is inlined into the final executable and spawned by
20it when needed.
21"""
22
23try:
24    import http.server as httpserver
25    import socketserver
26except ImportError:
27    import BaseHTTPServer as httpserver
28    import SocketServer as socketserver
29import argparse
30import os
31import socket
32import subprocess
33import sys
34import webbrowser
35if sys.version_info >= (3, 2):
36    from html import escape
37else:
38    from cgi import escape
39try:
40    from urllib.request import unquote
41except ImportError:
42    from urllib2 import unquote
43from collections import namedtuple
44
45Node = namedtuple('Node', ['inputs', 'rule', 'target', 'outputs'])
46
47# Ideally we'd allow you to navigate to a build edge or a build node,
48# with appropriate views for each.  But there's no way to *name* a build
49# edge so we can only display nodes.
50#
51# For a given node, it has at most one input edge, which has n
52# different inputs.  This becomes node.inputs.  (We leave out the
53# outputs of the input edge due to what follows.)  The node can have
54# multiple dependent output edges.  Rather than attempting to display
55# those, they are summarized by taking the union of all their outputs.
56#
57# This means there's no single view that shows you all inputs and outputs
58# of an edge.  But I think it's less confusing than alternatives.
59
60def match_strip(line, prefix):
61    if not line.startswith(prefix):
62        return (False, line)
63    return (True, line[len(prefix):])
64
65def html_escape(text):
66    return escape(text, quote=True)
67
68def parse(text):
69    lines = iter(text.split('\n'))
70
71    target = None
72    rule = None
73    inputs = []
74    outputs = []
75
76    try:
77        target = next(lines)[:-1]  # strip trailing colon
78
79        line = next(lines)
80        (match, rule) = match_strip(line, '  input: ')
81        if match:
82            (match, line) = match_strip(next(lines), '    ')
83            while match:
84                type = None
85                (match, line) = match_strip(line, '| ')
86                if match:
87                    type = 'implicit'
88                (match, line) = match_strip(line, '|| ')
89                if match:
90                    type = 'order-only'
91                inputs.append((line, type))
92                (match, line) = match_strip(next(lines), '    ')
93
94        match, _ = match_strip(line, '  outputs:')
95        if match:
96            (match, line) = match_strip(next(lines), '    ')
97            while match:
98                outputs.append(line)
99                (match, line) = match_strip(next(lines), '    ')
100    except StopIteration:
101        pass
102
103    return Node(inputs, rule, target, outputs)
104
105def create_page(body):
106    return '''<!DOCTYPE html>
107<style>
108body {
109    font-family: sans;
110    font-size: 0.8em;
111    margin: 4ex;
112}
113h1 {
114    font-weight: normal;
115    font-size: 140%;
116    text-align: center;
117    margin: 0;
118}
119h2 {
120    font-weight: normal;
121    font-size: 120%;
122}
123tt {
124    font-family: WebKitHack, monospace;
125    white-space: nowrap;
126}
127.filelist {
128  -webkit-columns: auto 2;
129}
130</style>
131''' + body
132
133def generate_html(node):
134    document = ['<h1><tt>%s</tt></h1>' % html_escape(node.target)]
135
136    if node.inputs:
137        document.append('<h2>target is built using rule <tt>%s</tt> of</h2>' %
138                        html_escape(node.rule))
139        if len(node.inputs) > 0:
140            document.append('<div class=filelist>')
141            for input, type in sorted(node.inputs):
142                extra = ''
143                if type:
144                    extra = ' (%s)' % html_escape(type)
145                document.append('<tt><a href="?%s">%s</a>%s</tt><br>' %
146                                (html_escape(input), html_escape(input), extra))
147            document.append('</div>')
148
149    if node.outputs:
150        document.append('<h2>dependent edges build:</h2>')
151        document.append('<div class=filelist>')
152        for output in sorted(node.outputs):
153            document.append('<tt><a href="?%s">%s</a></tt><br>' %
154                            (html_escape(output), html_escape(output)))
155        document.append('</div>')
156
157    return '\n'.join(document)
158
159def ninja_dump(target):
160    cmd = [args.ninja_command, '-f', args.f, '-t', 'query', target]
161    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
162                            universal_newlines=True)
163    return proc.communicate() + (proc.returncode,)
164
165class RequestHandler(httpserver.BaseHTTPRequestHandler):
166    def do_GET(self):
167        assert self.path[0] == '/'
168        target = unquote(self.path[1:])
169
170        if target == '':
171            self.send_response(302)
172            self.send_header('Location', '?' + args.initial_target)
173            self.end_headers()
174            return
175
176        if not target.startswith('?'):
177            self.send_response(404)
178            self.end_headers()
179            return
180        target = target[1:]
181
182        ninja_output, ninja_error, exit_code = ninja_dump(target)
183        if exit_code == 0:
184            page_body = generate_html(parse(ninja_output.strip()))
185        else:
186            # Relay ninja's error message.
187            page_body = '<h1><tt>%s</tt></h1>' % html_escape(ninja_error)
188
189        self.send_response(200)
190        self.end_headers()
191        self.wfile.write(create_page(page_body).encode('utf-8'))
192
193    def log_message(self, format, *args):
194        pass  # Swallow console spam.
195
196parser = argparse.ArgumentParser(prog='ninja -t browse')
197parser.add_argument('--port', '-p', default=8000, type=int,
198    help='Port number to use (default %(default)d)')
199parser.add_argument('--hostname', '-a', default='localhost', type=str,
200    help='Hostname to bind to (default %(default)s)')
201parser.add_argument('--no-browser', action='store_true',
202    help='Do not open a webbrowser on startup.')
203
204parser.add_argument('--ninja-command', default='ninja',
205    help='Path to ninja binary (default %(default)s)')
206parser.add_argument('-f', default='build.ninja',
207    help='Path to build.ninja file (default %(default)s)')
208parser.add_argument('initial_target', default='all', nargs='?',
209    help='Initial target to show (default %(default)s)')
210
211class HTTPServer(socketserver.ThreadingMixIn, httpserver.HTTPServer):
212    # terminate server immediately when Python exits.
213    daemon_threads = True
214
215args = parser.parse_args()
216port = args.port
217hostname = args.hostname
218httpd = HTTPServer((hostname,port), RequestHandler)
219try:
220    if hostname == "":
221        hostname = socket.gethostname()
222    print('Web server running on %s:%d, ctl-C to abort...' % (hostname,port) )
223    print('Web server pid %d' % os.getpid(), file=sys.stderr )
224    if not args.no_browser:
225        webbrowser.open_new('http://%s:%s' % (hostname, port) )
226    httpd.serve_forever()
227except KeyboardInterrupt:
228    print()
229    pass  # Swallow console spam.
230
231
232