diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 39e447e0..64785c92 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -29,6 +29,7 @@ phutil_register_library_map(array( 'ArcanistBundle' => 'parser/ArcanistBundle.php', 'ArcanistBundleTestCase' => 'parser/__tests__/ArcanistBundleTestCase.php', 'ArcanistCSSLintLinter' => 'lint/linter/ArcanistCSSLintLinter.php', + 'ArcanistCSSLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php', 'ArcanistCallConduitWorkflow' => 'workflow/ArcanistCallConduitWorkflow.php', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/ArcanistCapabilityNotSupportedException.php', 'ArcanistChooseInvalidRevisionException' => 'exception/ArcanistChooseInvalidRevisionException.php', @@ -60,6 +61,7 @@ phutil_register_library_map(array( 'ArcanistDownloadWorkflow' => 'workflow/ArcanistDownloadWorkflow.php', 'ArcanistEventType' => 'events/constant/ArcanistEventType.php', 'ArcanistExportWorkflow' => 'workflow/ArcanistExportWorkflow.php', + 'ArcanistExternalLinter' => 'lint/linter/ArcanistExternalLinter.php', 'ArcanistFeatureWorkflow' => 'workflow/ArcanistFeatureWorkflow.php', 'ArcanistFilenameLinter' => 'lint/linter/ArcanistFilenameLinter.php', 'ArcanistFlagWorkflow' => 'workflow/ArcanistFlagWorkflow.php', @@ -107,6 +109,7 @@ phutil_register_library_map(array( 'ArcanistNoLintLinter' => 'lint/linter/ArcanistNoLintLinter.php', 'ArcanistNoLintTestCaseMisnamed' => 'lint/linter/__tests__/ArcanistNoLintTestCase.php', 'ArcanistPEP8Linter' => 'lint/linter/ArcanistPEP8Linter.php', + 'ArcanistPEP8LinterTestCase' => 'lint/linter/__tests__/ArcanistPEP8LinterTestCase.php', 'ArcanistPasteWorkflow' => 'workflow/ArcanistPasteWorkflow.php', 'ArcanistPatchWorkflow' => 'workflow/ArcanistPatchWorkflow.php', 'ArcanistPhpcsLinter' => 'lint/linter/ArcanistPhpcsLinter.php', @@ -191,7 +194,8 @@ phutil_register_library_map(array( 'ArcanistBritishTestCase' => 'ArcanistTestCase', 'ArcanistBrowseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBundleTestCase' => 'ArcanistTestCase', - 'ArcanistCSSLintLinter' => 'ArcanistLinter', + 'ArcanistCSSLintLinter' => 'ArcanistExternalLinter', + 'ArcanistCSSLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCapabilityNotSupportedException' => 'Exception', 'ArcanistChooseInvalidRevisionException' => 'Exception', @@ -213,6 +217,7 @@ phutil_register_library_map(array( 'ArcanistDownloadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExportWorkflow' => 'ArcanistBaseWorkflow', + 'ArcanistExternalLinter' => 'ArcanistFutureLinter', 'ArcanistFeatureWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistFlagWorkflow' => 'ArcanistBaseWorkflow', @@ -248,7 +253,8 @@ phutil_register_library_map(array( 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintTestCaseMisnamed' => 'ArcanistLinterTestCase', - 'ArcanistPEP8Linter' => 'ArcanistFutureLinter', + 'ArcanistPEP8Linter' => 'ArcanistExternalLinter', + 'ArcanistPEP8LinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPasteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPhpcsLinter' => 'ArcanistLinter', diff --git a/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php b/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php index 672a3023..c2f8b5ef 100644 --- a/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php +++ b/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php @@ -36,24 +36,47 @@ final class ArcanistConfigurationDrivenLintEngine extends ArcanistLintEngine { $built_linters = array(); $all_paths = $this->getPaths(); foreach ($config['linters'] as $name => $spec) { + $type = idx($spec, 'type'); + if ($type !== null) { + if (empty($linters[$type])) { + $list = implode(', ', array_keys($linters)); + throw new Exception( + "Linter '{$name}' specifies invalid type '{$type}'. Available ". + "linters are: {$list}."); + } + + $linter = clone $linters[$type]; + $linter->setEngine($this); + $more = $linter->getLinterConfigurationOptions(); + } else { + // We'll raise an error below about the invalid "type" key. + $linter = null; + $more = array(); + } + PhutilTypeSpec::checkMap( $spec, array( 'type' => 'string', 'include' => 'optional string | list', 'exclude' => 'optional string | list', - )); + ) + $more); - $type = $spec['type']; - if (empty($linters[$type])) { - $list = implode(', ', array_keys($linters)); - throw new Exception( - "Linter '{$name}' specifies invalid type '{$type}'. Available ". - "linters are: {$list}."); + foreach ($more as $key => $value) { + if (array_key_exists($key, $spec)) { + try { + $linter->setLinterConfigurationValue($key, $spec); + } catch (Exception $ex) { + $message = pht( + 'Error in parsing ".arclint" file, in key "%s" for '. + 'linter "%s".', + $key, + $name); + throw new PhutilProxyException($message, $ex); + } + } } - $linter = clone $linters[$type]; - $include = (array)idx($spec, 'include', array()); $exclude = (array)idx($spec, 'exclude', array()); @@ -69,6 +92,7 @@ final class ArcanistConfigurationDrivenLintEngine extends ArcanistLintEngine { $linter->setPaths($paths); + $built_linters[] = $linter; } @@ -180,4 +204,5 @@ final class ArcanistConfigurationDrivenLintEngine extends ArcanistLintEngine { call_user_func_array(array($console, 'writeErr'), $argv); } + } diff --git a/src/lint/linter/ArcanistCSSLintLinter.php b/src/lint/linter/ArcanistCSSLintLinter.php index 81042cc6..a40c3cdc 100644 --- a/src/lint/linter/ArcanistCSSLintLinter.php +++ b/src/lint/linter/ArcanistCSSLintLinter.php @@ -13,81 +13,50 @@ * * @group linter */ -final class ArcanistCSSLintLinter extends ArcanistLinter { - - private $reports; +final class ArcanistCSSLintLinter extends ArcanistExternalLinter { public function getLinterName() { return 'CSSLint'; } - public function getLintSeverityMap() { - return array(); + public function getMandatoryFlags() { + return '--format=lint-xml'; } - public function getLintNameMap() { - return array(); - } - - public function getCSSLintOptions() { + public function getDefaultFlags() { $working_copy = $this->getEngine()->getWorkingCopy(); $options = $working_copy->getConfig('lint.csslint.options'); + // TODO: Deprecation warning. return $options; } - private function getCSSLintPath() { + public function getDefaultBinary() { + // TODO: Deprecation warning. $working_copy = $this->getEngine()->getWorkingCopy(); $bin = $working_copy->getConfig('lint.csslint.bin'); - - if ($bin === null) { - $bin = 'csslint'; + if ($bin) { + return $bin; } - return $bin; + return 'csslint'; } - public function willLintPaths(array $paths) { - $csslint_bin = $this->getCSSLintPath(); - $csslint_options = $this->getCSSLintOptions(); - $futures = array(); - - foreach ($paths as $path) { - $filepath = $this->getEngine()->getFilePathOnDisk($path); - $this->reports[$path] = new TempFile(); - $futures[$path] = new ExecFuture('%C %C --format=lint-xml >%s %s', - $csslint_bin, - $csslint_options, - $this->reports[$path], - $filepath); - } - - foreach (Futures($futures)->limit(8) as $path => $future) { - $this->results[$path] = $future->resolve(); - } - - libxml_use_internal_errors(true); + public function getInstallInstructions() { + return pht('Install CSSLint using `npm install -g csslint`.'); } - public function lintPath($path) { - list($rc, $stdout) = $this->results[$path]; + protected function parseLinterOutput($path, $err, $stdout, $stderr) { + $report_dom = new DOMDocument(); + $ok = @$report_dom->loadXML($stdout); - $report = Filesystem::readFile($this->reports[$path]); - - if ($report) { - $report_dom = new DOMDocument(); - libxml_clear_errors(); - $report_dom->loadXML($report); - } - if (!$report || libxml_get_errors()) { - throw new ArcanistUsageException('CSS Linter failed to load ' . - 'reporting file. Something happened when running csslint. ' . - "Output:\n$stdout" . - "\nTry running lint with --trace flag to get more details."); + if (!$ok) { + return false; } $files = $report_dom->getElementsByTagName('file'); + $messages = array(); foreach ($files as $file) { foreach ($file->childNodes as $child) { if (!($child instanceof DOMElement)) { @@ -118,8 +87,10 @@ final class ArcanistCSSLintLinter extends ArcanistLinter { $message->setOriginalText($text); } - $this->addLintMessage($message); + $messages[] = $message; } } + + return $messages; } } diff --git a/src/lint/linter/ArcanistExternalLinter.php b/src/lint/linter/ArcanistExternalLinter.php new file mode 100644 index 00000000..d8423754 --- /dev/null +++ b/src/lint/linter/ArcanistExternalLinter.php @@ -0,0 +1,473 @@ +flags = $flags; + return $this; + } + + + /** + * Return the binary or script to execute. This method synthesizes defaults + * and configuration. You can override the binary with @{method:setBinary}. + * + * @return string Binary to execute. + * @task bin + */ + final public function getBinary() { + return coalesce($this->bin, $this->getDefaultBinary()); + } + + + /** + * Override the default binary with a new one. + * + * @param string New binary. + * @return this + * @task bin + */ + final public function setBinary($bin) { + $this->bin = $bin; + return $this; + } + + + /** + * Return true if this linter should use an interpreter (like "python" or + * "node") in addition to the script. + * + * After overriding this method to return `true`, override + * @{method:getDefaultInterpreter} to set a default. + * + * @return bool True to use an interpreter. + * @task bin + */ + public function shouldUseInterpreter() { + return false; + } + + + /** + * Return the default interpreter, like "python" or "node". This method is + * only invoked if @{method:shouldUseInterpreter} has been overridden to + * return `true`. + * + * @return string Default interpreter. + * @task bin + */ + public function getDefaultInterpreter() { + throw new Exception("Incomplete implementation!"); + } + + + /** + * Get the effective interpreter. This method synthesizes configuration and + * defaults. + * + * @return string Effective interpreter. + * @task bin + */ + final public function getInterpreter() { + return coalesce($this->interpreter, $this->getDefaultInterpreter()); + } + + + /** + * Set the interpreter, overriding any default. + * + * @param string New interpreter. + * @return this + * @task bin + */ + final public function setInterpreter($interpreter) { + $this->interpreter = $interpreter; + return $this; + } + + +/* -( Parsing Linter Output )---------------------------------------------- */ + + + /** + * Parse the output of the external lint program into objects of class + * @{class:ArcanistLintMessage} which `arc` can consume. Generally, this + * means examining the output and converting each warning or error into a + * message. + * + * If parsing fails, returning `false` will cause the caller to throw an + * appropriate exception. (You can also throw a more specific exception if + * you're able to detect a more specific condition.) Otherwise, return a list + * of messages. + * + * @param string Path to the file being linted. + * @param int Exit code of the linter. + * @param string Stdout of the linter. + * @param string Stderr of the linter. + * @return list|false List of lint messages, or false + * to indicate parser failure. + * @task parse + */ + abstract protected function parseLinterOutput($path, $err, $stdout, $stderr); + + +/* -( Executing the Linter )----------------------------------------------- */ + + + /** + * Check that the binary and interpreter (if applicable) exist, and throw + * an exception with a message about how to install them if they do not. + * + * @return void + */ + final public function checkBinaryConfiguration() { + $interpreter = null; + if ($this->shouldUseInterpreter()) { + $interpreter = $this->getInterpreter(); + } + + $binary = $this->getBinary(); + + // NOTE: If we have an interpreter, we don't require the script to be + // executable (so we just check that the path exists). Otherwise, the + // binary must be executable. + + if ($interpreter) { + if (!Filesystem::binaryExists($interpreter)) { + throw new ArcanistUsageException( + pht( + 'Unable to locate interpreter "%s" to run linter %s. You may '. + 'need to install the intepreter, or adjust your linter '. + 'configuration.', + "\nTO INSTALL: %s", + $interpreter, + get_class($this), + $this->getInstallInstructions())); + } + if (!Filesystem::pathExists($binary)) { + throw new ArcanistUsageException( + pht( + 'Unable to locate script "%s" to run linter %s. You may need '. + 'to install the script, or adjust your linter configuration. '. + "\nTO INSTALL: %s", + $binary, + get_class($this), + $this->getInstallInstructions())); + } + } else { + if (!Filesystem::binaryExists($binary)) { + throw new ArcanistUsageException( + pht( + 'Unable to locate binary "%s" to run linter %s. You may need '. + 'to install the binary, or adjust your linter configuration. '. + "\nTO INSTALL: %s", + $binary, + get_class($this), + $this->getInstallInstructions())); + } + } + } + + + /** + * Get the composed executable command, including the interpreter and binary + * but without flags or paths. This can be used to execute `--version` + * commands. + * + * @return string Command to execute the raw linter. + * @task exec + */ + protected function getExecutableCommand() { + $this->checkBinaryConfiguration(); + + $interpreter = null; + if ($this->shouldUseInterpreter()) { + $interpreter = $this->getInterpreter(); + } + + $binary = $this->getBinary(); + + if ($interpreter) { + $bin = csprintf('%s %s', $interpreter, $binary); + } else { + $bin = csprintf('%s', $binary); + } + + return $bin; + } + + + /** + * Get the composed flags for the executable, including both mandatory and + * configured flags. + * + * @return string Composed flags. + * @task exec + */ + protected function getCommandFlags() { + return csprintf( + '%C %C', + $this->getMandatoryFlags(), + coalesce($this->flags, $this->getDefaultFlags())); + } + + + protected function buildFutures(array $paths) { + $executable = $this->getExecutableCommand(); + + $bin = csprintf('%C %C', $executable, $this->getCommandFlags()); + + $futures = array(); + foreach ($paths as $path) { + if ($this->supportsReadDataFromStdin()) { + $future = new ExecFuture( + '%C %C', + $bin, + $this->getReadDataFromStdinFilename()); + $future->write($this->getFileData($path)); + } else { + // TODO: In commit hook mode, we need to do more handling here. + $disk_path = $this->getEngine()->getFilePathOnDisk($path); + $future = new ExecFuture('%C %s', $bin, $disk_path); + } + + $futures[$path] = $future; + } + + return $futures; + } + + protected function resolveFuture($path, Future $future) { + list($err, $stdout, $stderr) = $future->resolve(); + if ($err && !$this->shouldExpectCommandErrors()) { + $future->resolvex(); + } + + $messages = $this->parseLinterOutput($path, $err, $stdout, $stderr); + + if ($messages === false) { + $future->resolvex(); + return; + } + + foreach ($messages as $message) { + $this->addLintMessage($message); + } + } + + + public function getLinterConfigurationOptions() { + $options = array( + 'bin' => 'optional string | list', + 'flags' => 'optional string', + ); + + if ($this->shouldUseInterpreter()) { + $options['interpreter'] = 'optional string | list'; + } + + return $options; + } + + public function setLinterConfigurationValue($key, $value) { + switch ($key) { + case 'interpreter': + $working_copy = $this->getEngine()->getWorkingCopy(); + $root = $working_copy->getProjectRoot(); + + foreach ((array)$value as $path) { + if (Filesystem::binaryExists($path)) { + $this->setInterpreter($path); + return; + } + + $path = Filesystem::resolvePath($path, $root); + + if (Filesystem::binaryExists($path)) { + $this->setInterpreter($path); + return; + } + } + + throw new Exception( + pht('None of the configured interpreters can be located.')); + case 'bin': + $is_script = $this->shouldUseInterpreter(); + + $working_copy = $this->getEngine()->getWorkingCopy(); + $root = $working_copy->getProjectRoot(); + + foreach ((array)$value as $path) { + if (!$is_script && Filesystem::binaryExists($path)) { + $this->setBinary($path); + return; + } + + $path = Filesystem::resolvePath($path, $root); + if ((!$is_script && Filesystem::binaryExists($path)) || + ($is_script && Filesystem::pathExists($path))) { + $this->setBinary($path); + return; + } + } + + throw new Exception( + pht('None of the configured binaries can be located.')); + case 'flags': + if (strlen($value)) { + $this->setFlags($value); + } + break; + } + + return parent::setLinterConfigurationValue($key, $value); + } + +} diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php index d22fd48b..316538b2 100644 --- a/src/lint/linter/ArcanistLinter.php +++ b/src/lint/linter/ArcanistLinter.php @@ -256,4 +256,12 @@ abstract class ArcanistLinter { return null; } + public function getLinterConfigurationOptions() { + return array(); + } + + public function setLinterConfigurationValue($key, $value) { + throw new Exception("Incomplete implementation: {$key}!"); + } + } diff --git a/src/lint/linter/ArcanistPEP8Linter.php b/src/lint/linter/ArcanistPEP8Linter.php index 346fd2ef..c9d26990 100644 --- a/src/lint/linter/ArcanistPEP8Linter.php +++ b/src/lint/linter/ArcanistPEP8Linter.php @@ -5,26 +5,20 @@ * * @group linter */ -final class ArcanistPEP8Linter extends ArcanistFutureLinter { +final class ArcanistPEP8Linter extends ArcanistExternalLinter { public function getLinterName() { return 'PEP8'; } - public function getLintSeverityMap() { - return array(); - } - - public function getLintNameMap() { - return array(); - } - public function getCacheVersion() { - list($stdout) = execx('%C --version', $this->getPEP8Path()); - return $stdout.$this->getPEP8Options(); + list($stdout) = execx('%C --version', $this->getExecutableCommand()); + return $stdout.$this->getCommandFlags(); } - public function getPEP8Options() { + public function getDefaultFlags() { + // TODO: Warn that all of this is deprecated. + $working_copy = $this->getEngine()->getWorkingCopy(); $options = $working_copy->getConfig('lint.pep8.options'); @@ -35,73 +29,44 @@ final class ArcanistPEP8Linter extends ArcanistFutureLinter { return $options; } - public function getPEP8Path() { + public function shouldUseInterpreter() { + return ($this->getDefaultBinary() !== 'pep8'); + } + + public function getDefaultInterpreter() { + return 'python2.6'; + } + + public function getDefaultBinary() { + if (Filesystem::binaryExists('pep8')) { + return 'pep8'; + } + $working_copy = $this->getEngine()->getWorkingCopy(); - $prefix = $working_copy->getConfig('lint.pep8.prefix'); - $bin = $working_copy->getConfig('lint.pep8.bin'); + $old_prefix = $working_copy->getConfig('lint.pep8.prefix'); + $old_bin = $working_copy->getConfig('lint.pep8.bin'); - if ($bin === null && $prefix === null) { - $bin = csprintf('/usr/bin/env python2.6 %s', - phutil_get_library_root('arcanist'). - '/../externals/pep8/pep8.py'); - } else { - if ($bin === null) { - $bin = 'pep8'; - } - - if ($prefix !== null) { - if (!Filesystem::pathExists($prefix.'/'.$bin)) { - throw new ArcanistUsageException( - "Unable to find PEP8 binary in a specified directory. Make sure ". - "that 'lint.pep8.prefix' and 'lint.pep8.bin' keys are set ". - "correctly. If you'd rather use a copy of PEP8 installed ". - "globally, you can just remove these keys from your .arcconfig."); - } - - $bin = csprintf("%s/%s", $prefix, $bin); - - return $bin; - } - - // Look for globally installed PEP8 - list($err) = exec_manual('which %s', $bin); - if ($err) { - throw new ArcanistUsageException( - "PEP8 does not appear to be installed on this system. Install it ". - "(e.g., with 'easy_install pep8') or configure ". - "'lint.pep8.prefix' in your .arcconfig to point to the directory ". - "where it resides."); - } + if ($old_prefix || $old_bin) { + // TODO: Deprecation warning. + $old_bin = nonempty($old_bin, 'pep8'); + return $old_prefix.'/'.$old_bin; } - return $bin; + $arc_root = dirname(phutil_get_library_root('arcanist')); + return $arc_root.'/externals/pep8/pep8.py'; } - protected function buildFutures(array $paths) { - $severity = ArcanistLintSeverity::SEVERITY_WARNING; - if (!$this->getEngine()->isSeverityEnabled($severity)) { - return; - } - - $pep8_bin = $this->getPEP8Path(); - $options = $this->getPEP8Options(); - - $futures = array(); - - foreach ($paths as $path) { - $futures[$path] = new ExecFuture( - "%C %C %s", - $pep8_bin, - $options, - $this->getEngine()->getFilePathOnDisk($path)); - } - - return $futures; + public function getInstallInstructions() { + return pht('Install PEP8 using `easy_install pep8`.'); } - protected function resolveFuture($path, Future $future) { - list($rc, $stdout) = $future->resolve(); - $lines = explode("\n", $stdout); + public function shouldExpectCommandErrors() { + return true; + } + + protected function parseLinterOutput($path, $err, $stdout, $stderr) { + $lines = phutil_split_lines($stdout, $retain_endings = false); + $messages = array(); foreach ($lines as $line) { $matches = null; @@ -122,8 +87,15 @@ final class ArcanistPEP8Linter extends ArcanistFutureLinter { $message->setName('PEP8 '.$matches[4]); $message->setDescription($matches[5]); $message->setSeverity(ArcanistLintSeverity::SEVERITY_WARNING); - $this->addLintMessage($message); + + $messages[] = $message; } + + if ($err && !$messages) { + return false; + } + + return $messages; } } diff --git a/src/lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php b/src/lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php new file mode 100644 index 00000000..5e869966 --- /dev/null +++ b/src/lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php @@ -0,0 +1,12 @@ +executeTestsInDirectory( + dirname(__FILE__).'/csslint/', + new ArcanistCSSLintLinter()); + } + +} diff --git a/src/lint/linter/__tests__/ArcanistPEP8LinterTestCase.php b/src/lint/linter/__tests__/ArcanistPEP8LinterTestCase.php new file mode 100644 index 00000000..184e4879 --- /dev/null +++ b/src/lint/linter/__tests__/ArcanistPEP8LinterTestCase.php @@ -0,0 +1,12 @@ +executeTestsInDirectory( + dirname(__FILE__).'/pep8/', + new ArcanistPEP8Linter()); + } + +} diff --git a/src/lint/linter/__tests__/csslint/duplicate-properties.lint-test b/src/lint/linter/__tests__/csslint/duplicate-properties.lint-test new file mode 100644 index 00000000..5883bbcb --- /dev/null +++ b/src/lint/linter/__tests__/csslint/duplicate-properties.lint-test @@ -0,0 +1,10 @@ +.rule { + font-weight: bold; + font-weight: bold; + font-weight: bold; + font-weight: bold; +} +~~~~~~~~~~ +warning:3:3 +warning:4:3 +warning:5:3 diff --git a/src/lint/linter/__tests__/csslint/empty-rule.lint-test b/src/lint/linter/__tests__/csslint/empty-rule.lint-test new file mode 100644 index 00000000..9a055388 --- /dev/null +++ b/src/lint/linter/__tests__/csslint/empty-rule.lint-test @@ -0,0 +1,3 @@ +.rule { } +~~~~~~~~~~ +warning:1:1 diff --git a/src/lint/linter/__tests__/pep8/imports.lint-test b/src/lint/linter/__tests__/pep8/imports.lint-test new file mode 100644 index 00000000..54ef8efd --- /dev/null +++ b/src/lint/linter/__tests__/pep8/imports.lint-test @@ -0,0 +1,3 @@ +import os, sys +~~~~~~~~~~ +warning:1:10