1# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
2# vim: set filetype=python:
3# This Source Code Form is subject to the terms of the Mozilla Public
4# License, v. 2.0. If a copy of the MPL was not distributed with this
5# file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7from filecmp import dircmp
8import json
9import os
10import platform
11import re
12from mozfile.mozfile import remove as mozfileremove
13import subprocess
14import sys
15from distutils.version import LooseVersion
16sys.path.append(os.path.join(
17    os.path.dirname(__file__), "..", "..", "..", "third_party", "python", "which"))
18import which
19
20NODE_MIN_VERSION = "6.9.1"
21NPM_MIN_VERSION = "3.10.8"
22
23NODE_MACHING_VERSION_NOT_FOUND_MESSAGE = """
24nodejs is out of date. You currently have node v%s but v%s is required.
25Please update nodejs from https://nodejs.org and try again.
26""".strip()
27
28NPM_MACHING_VERSION_NOT_FOUND_MESSAGE = """
29npm is out of date. You currently have npm v%s but v%s is required.
30You can usually update npm with:
31
32npm i -g npm
33""".strip()
34
35NODE_NOT_FOUND_MESSAGE = """
36nodejs is either not installed or is installed to a non-standard path.
37Please install nodejs from https://nodejs.org and try again.
38
39Valid installation paths:
40""".strip()
41
42NPM_NOT_FOUND_MESSAGE = """
43Node Package Manager (npm) is either not installed or installed to a
44non-standard path. Please install npm from https://nodejs.org (it comes as an
45option in the node installation) and try again.
46
47Valid installation paths:
48""".strip()
49
50
51VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
52CARET_VERSION_RANGE_RE = re.compile(r"^\^((\d+)\.\d+\.\d+)$")
53
54project_root = None
55
56
57def eslint_maybe_setup():
58    """Setup ESLint only if it is needed."""
59    has_issues, needs_clobber = eslint_module_needs_setup()
60
61    if has_issues:
62        eslint_setup(needs_clobber)
63
64
65def eslint_setup(should_clobber=False):
66    """Ensure eslint is optimally configured.
67
68    This command will inspect your eslint configuration and
69    guide you through an interactive wizard helping you configure
70    eslint for optimal use on Mozilla projects.
71    """
72    orig_cwd = os.getcwd()
73    sys.path.append(os.path.dirname(__file__))
74
75    # npm sometimes fails to respect cwd when it is run using check_call so
76    # we manually switch folders here instead.
77    project_root = get_project_root()
78    os.chdir(project_root)
79
80    if should_clobber:
81        node_modules_path = os.path.join(project_root, "node_modules")
82        print("Clobbering node_modules...")
83        if sys.platform.startswith('win') and have_winrm():
84            process = subprocess.Popen(['winrm', '-rf', node_modules_path])
85            process.wait()
86        else:
87            mozfileremove(node_modules_path)
88
89    npm_path = get_node_or_npm_path("npm")
90    if not npm_path:
91        return 1
92
93    extra_parameters = ["--loglevel=error"]
94
95    # Install ESLint and external plugins
96    cmd = [npm_path, "install"]
97    cmd.extend(extra_parameters)
98    print("Installing eslint for mach using \"%s\"..." % (" ".join(cmd)))
99    if not call_process("eslint", cmd):
100        return 1
101
102    eslint_path = os.path.join(get_project_root(), "node_modules", ".bin", "eslint")
103
104    print("\nESLint and approved plugins installed successfully!")
105    print("\nNOTE: Your local eslint binary is at %s\n" % eslint_path)
106
107    os.chdir(orig_cwd)
108
109
110def call_process(name, cmd, cwd=None):
111    try:
112        with open(os.devnull, "w") as fnull:
113            subprocess.check_call(cmd, cwd=cwd, stdout=fnull)
114    except subprocess.CalledProcessError:
115        if cwd:
116            print("\nError installing %s in the %s folder, aborting." % (name, cwd))
117        else:
118            print("\nError installing %s, aborting." % name)
119
120        return False
121
122    return True
123
124
125def expected_eslint_modules():
126    # Read the expected version of ESLint and external modules
127    expected_modules_path = os.path.join(get_project_root(), "package.json")
128    with open(expected_modules_path, "r") as f:
129        expected_modules = json.load(f)["dependencies"]
130
131    # Also read the in-tree ESLint plugin mozilla information, to ensure the
132    # dependencies are up to date.
133    mozilla_json_path = os.path.join(get_eslint_module_path(),
134                                     "eslint-plugin-mozilla", "package.json")
135    with open(mozilla_json_path, "r") as f:
136        expected_modules.update(json.load(f)["dependencies"])
137
138    # Also read the in-tree ESLint plugin spidermonkey information, to ensure the
139    # dependencies are up to date.
140    mozilla_json_path = os.path.join(get_eslint_module_path(),
141                                     "eslint-plugin-spidermonkey-js", "package.json")
142    with open(mozilla_json_path, "r") as f:
143        expected_modules.update(json.load(f)["dependencies"])
144
145    return expected_modules
146
147
148def check_eslint_files(node_modules_path, name):
149    def check_file_diffs(dcmp):
150        # Diff files only looks at files that are different. Not for files
151        # that are only present on one side. This should be generally OK as
152        # new files will need to be added in the index.js for the package.
153        if dcmp.diff_files and dcmp.diff_files != ['package.json']:
154            return True
155
156        result = False
157
158        # Again, we only look at common sub directories for the same reason
159        # as above.
160        for sub_dcmp in dcmp.subdirs.values():
161            result = result or check_file_diffs(sub_dcmp)
162
163        return result
164
165    dcmp = dircmp(os.path.join(node_modules_path, name),
166                  os.path.join(get_eslint_module_path(), name))
167
168    return check_file_diffs(dcmp)
169
170
171def eslint_module_needs_setup():
172    has_issues = False
173    needs_clobber = False
174    node_modules_path = os.path.join(get_project_root(), "node_modules")
175
176    for name, expected_data in expected_eslint_modules().iteritems():
177        # expected_eslint_modules returns a string for the version number of
178        # dependencies for installation of eslint generally, and an object
179        # for our in-tree plugins (which contains the entire module info).
180        if "version" in expected_data:
181            version_range = expected_data["version"]
182        else:
183            version_range = expected_data
184
185        path = os.path.join(node_modules_path, name, "package.json")
186
187        if not os.path.exists(path):
188            print("%s v%s needs to be installed locally." % (name, version_range))
189            has_issues = True
190            continue
191
192        data = json.load(open(path))
193
194        if version_range.startswith("file:"):
195            # We don't need to check local file installations for versions, as
196            # these are symlinked, so we'll always pick up the latest.
197            continue
198
199        if name == "eslint" and LooseVersion("4.0.0") > LooseVersion(data["version"]):
200            print("ESLint is an old version, clobbering node_modules directory")
201            needs_clobber = True
202            has_issues = True
203            continue
204
205        if not version_in_range(data["version"], version_range):
206            print("%s v%s should be v%s." % (name, data["version"], version_range))
207            has_issues = True
208            continue
209
210    return has_issues, needs_clobber
211
212
213def version_in_range(version, version_range):
214    """
215    Check if a module version is inside a version range.  Only supports explicit versions and
216    caret ranges for the moment, since that's all we've used so far.
217    """
218    if version == version_range:
219        return True
220
221    version_match = VERSION_RE.match(version)
222    if not version_match:
223        raise RuntimeError("mach eslint doesn't understand module version %s" % version)
224    version = LooseVersion(version)
225
226    # Caret ranges as specified by npm allow changes that do not modify the left-most non-zero
227    # digit in the [major, minor, patch] tuple.  The code below assumes the major digit is
228    # non-zero.
229    range_match = CARET_VERSION_RANGE_RE.match(version_range)
230    if range_match:
231        range_version = range_match.group(1)
232        range_major = int(range_match.group(2))
233
234        range_min = LooseVersion(range_version)
235        range_max = LooseVersion("%d.0.0" % (range_major + 1))
236
237        return range_min <= version < range_max
238
239    return False
240
241
242def get_possible_node_paths_win():
243    """
244    Return possible nodejs paths on Windows.
245    """
246    if platform.system() != "Windows":
247        return []
248
249    return list({
250        "%s\\nodejs" % os.environ.get("SystemDrive"),
251        os.path.join(os.environ.get("ProgramFiles"), "nodejs"),
252        os.path.join(os.environ.get("PROGRAMW6432"), "nodejs"),
253        os.path.join(os.environ.get("PROGRAMFILES"), "nodejs")
254    })
255
256
257def simple_which(filename, path=None):
258    exts = [".cmd", ".exe", ""] if platform.system() == "Windows" else [""]
259
260    for ext in exts:
261        try:
262            return which.which(filename + ext, path)
263        except which.WhichError:
264            pass
265
266    # If we got this far, we didn't find it with any of the extensions, so
267    # just return.
268    return None
269
270
271def which_path(filename):
272    """
273    Return the nodejs or npm path.
274    """
275    # Look in the system path first.
276    path = simple_which(filename)
277    if path is not None:
278        return path
279
280    if platform.system() == "Windows":
281        # If we didn't find it fallback to the non-system paths.
282        path = simple_which(filename, get_possible_node_paths_win())
283    elif filename == "node":
284        path = simple_which("nodejs")
285
286    return path
287
288
289def get_node_or_npm_path(filename, minversion=None):
290    node_or_npm_path = which_path(filename)
291
292    if not node_or_npm_path:
293        if filename in ('node', 'nodejs'):
294            print(NODE_NOT_FOUND_MESSAGE)
295        elif filename == "npm":
296            print(NPM_NOT_FOUND_MESSAGE)
297
298        if platform.system() == "Windows":
299            app_paths = get_possible_node_paths_win()
300
301            for p in app_paths:
302                print("  - %s" % p)
303        elif platform.system() == "Darwin":
304            print("  - /usr/local/bin/{}".format(filename))
305        elif platform.system() == "Linux":
306            print("  - /usr/bin/{}".format(filename))
307
308        return None
309
310    if not minversion:
311        return node_or_npm_path
312
313    version_str = get_version(node_or_npm_path).lstrip('v')
314
315    version = LooseVersion(version_str)
316
317    if version > minversion:
318        return node_or_npm_path
319
320    if filename == "npm":
321        print(NPM_MACHING_VERSION_NOT_FOUND_MESSAGE % (version_str.strip(), minversion))
322    else:
323        print(NODE_MACHING_VERSION_NOT_FOUND_MESSAGE % (version_str.strip(), minversion))
324
325    return None
326
327
328def get_version(path):
329    try:
330        version_str = subprocess.check_output([path, "--version"],
331                                              stderr=subprocess.STDOUT)
332        return version_str
333    except (subprocess.CalledProcessError, OSError):
334        return None
335
336
337def set_project_root(root=None):
338    """Sets the project root to the supplied path, or works out what the root
339    is based on looking for 'mach'.
340
341    Keyword arguments:
342    root - (optional) The path to set the root to.
343    """
344    global project_root
345
346    if root:
347        project_root = root
348        return
349
350    file_found = False
351    folder = os.getcwd()
352
353    while (folder):
354        if os.path.exists(os.path.join(folder, 'mach')):
355            file_found = True
356            break
357        else:
358            folder = os.path.dirname(folder)
359
360    if file_found:
361        project_root = os.path.abspath(folder)
362
363
364def get_project_root():
365    """Returns the absolute path to the root of the project, see set_project_root()
366    for how this is determined.
367    """
368    global project_root
369
370    if not project_root:
371        set_project_root()
372
373    return project_root
374
375
376def get_eslint_module_path():
377    return os.path.join(get_project_root(), "tools", "lint", "eslint")
378
379
380def check_node_executables_valid():
381    # eslint requires at least node 6.9.1
382    node_path = get_node_or_npm_path("node", LooseVersion(NODE_MIN_VERSION))
383    if not node_path:
384        return False
385
386    npm_path = get_node_or_npm_path("npm", LooseVersion(NPM_MIN_VERSION))
387    if not npm_path:
388        return False
389
390    return True
391
392
393def have_winrm():
394    # `winrm -h` should print 'winrm version ...' and exit 1
395    try:
396        p = subprocess.Popen(['winrm.exe', '-h'],
397                             stdout=subprocess.PIPE,
398                             stderr=subprocess.STDOUT)
399        return p.wait() == 1 and p.stdout.read().startswith('winrm')
400    except Exception:
401        return False
402