diff --git a/src/lint/linter/ArcanistTextLinter.php b/src/lint/linter/ArcanistTextLinter.php --- a/src/lint/linter/ArcanistTextLinter.php +++ b/src/lint/linter/ArcanistTextLinter.php @@ -5,15 +5,16 @@ */ final class ArcanistTextLinter extends ArcanistLinter { - const LINT_DOS_NEWLINE = 1; - const LINT_TAB_LITERAL = 2; + const LINT_END_OF_LINE = 1; + const LINT_INDENT_STYLE = 2; const LINT_LINE_WRAP = 3; const LINT_EOF_NEWLINE = 4; - const LINT_BAD_CHARSET = 5; + const LINT_CHARSET = 5; const LINT_TRAILING_WHITESPACE = 6; const LINT_BOF_WHITESPACE = 8; const LINT_EOF_WHITESPACE = 9; + private $editorconfig; private $maxLineLength = 80; public function getInfoName() { @@ -23,7 +24,7 @@ public function getInfoDescription() { return pht( 'Enforces basic text rules like line length, character encoding, '. - 'and trailing whitespace.'); + 'and trailing whitespace. Supports EditorConfig configuration.'); } public function getLinterPriority() { @@ -36,7 +37,9 @@ 'type' => 'optional int', 'help' => pht( 'Adjust the maximum line length before a warning is raised. By '. - 'default, a warning is raised on lines exceeding 80 characters.'), + 'default, a warning is raised on lines exceeding 80 characters. '. + 'This is deprecated and you should use an EditorConfig file to '. + 'configure this value.'), ), ); @@ -51,6 +54,9 @@ public function setLinterConfigurationValue($key, $value) { switch ($key) { case 'text.max-line-length': + phutil_deprecated( + $key, + 'You should specify this configuration in an EditorConfig file.'); $this->setMaxLineLength($value); return; } @@ -69,25 +75,30 @@ public function getLintSeverityMap() { return array( self::LINT_LINE_WRAP => ArcanistLintSeverity::SEVERITY_WARNING, - self::LINT_TRAILING_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, - self::LINT_BOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, - self::LINT_EOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, + self::LINT_TRAILING_WHITESPACE => ArcanistLintSeverity::SEVERITY_ADVICE, + self::LINT_BOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_ADVICE, + self::LINT_EOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_ADVICE, ); } public function getLintNameMap() { return array( - self::LINT_DOS_NEWLINE => pht('DOS Newlines'), - self::LINT_TAB_LITERAL => pht('Tab Literal'), + self::LINT_END_OF_LINE => pht('End Of Line'), + self::LINT_INDENT_STYLE => pht('Indent Style'), self::LINT_LINE_WRAP => pht('Line Too Long'), self::LINT_EOF_NEWLINE => pht('File Does Not End in Newline'), - self::LINT_BAD_CHARSET => pht('Bad Charset'), + self::LINT_CHARSET => pht('Bad Charset'), self::LINT_TRAILING_WHITESPACE => pht('Trailing Whitespace'), self::LINT_BOF_WHITESPACE => pht('Leading Whitespace at BOF'), self::LINT_EOF_WHITESPACE => pht('Trailing Whitespace at EOF'), ); } + public function willLintPaths(array $paths) { + $root = $this->getEngine()->getWorkingCopy()->getProjectRoot(); + $this->editorconfig = new PhutilEditorConfig($root); + } + public function lintPath($path) { if (!strlen($this->getData($path))) { // If the file is empty, don't bother; particularly, don't require @@ -95,8 +106,8 @@ return; } - $this->lintNewlines($path); - $this->lintTabs($path); + $this->lintEndOfLine($path); + $this->lintIndentStyle($path); if ($this->didStopAllLinters()) { return; @@ -116,39 +127,123 @@ $this->lintEOFWhitespace($path); } - protected function lintNewlines($path) { + private function lintEndOfLine($path) { + switch ($this->getEditorConfig($path, PhutilEditorConfig::END_OF_LINE)) { + case 'lf': + $search = "\r"; + $replace = array( + "\r\n" => "\n", + ); + break; + + case 'cr': + $search = "\n"; + $replace = array( + "\r\n" => "\r", + ); + break; + + case 'lfcr': + $search = "\n"; + $replace = array( + "\r\n" => "\n\r", + ); + break; + + default: + return; + } + $data = $this->getData($path); - $pos = strpos($this->getData($path), "\r"); + $pos = strpos($this->getData($path), $search); if ($pos !== false) { $this->raiseLintAtOffset( 0, - self::LINT_DOS_NEWLINE, - pht('You must use ONLY Unix linebreaks ("%s") in source code.', '\n'), + self::LINT_END_OF_LINE, + pht('Invalid end-of-line character(s).'), $data, - str_replace("\r\n", "\n", $data)); + str_replace(array_keys($replace), array_values($replace), $data)); - if ($this->isMessageEnabled(self::LINT_DOS_NEWLINE)) { + if ($this->isMessageEnabled(self::LINT_END_OF_LINE)) { $this->stopAllLinters(); } } } - protected function lintTabs($path) { + private function lintIndentStyle($path) { + switch ($this->getEditorConfig($path, PhutilEditorConfig::INDENT_STYLE)) { + case 'space': + break; + + case 'tab': + // TODO: Can we do anything here? + return; + + default: + return; + } + $pos = strpos($this->getData($path), "\t"); if ($pos !== false) { $this->raiseLintAtOffset( $pos, - self::LINT_TAB_LITERAL, + self::LINT_INDENT_STYLE, pht('Configure your editor to use spaces for indentation.'), "\t"); } } - protected function lintLineLength($path) { - $lines = explode("\n", $this->getData($path)); + private function lintCharset($path) { + switch ($this->getEditorConfig($path, PhutilEditorConfig::CHARSET)) { + case 'latin1': + $charset = 'ISO-8859-1'; + break; + + case 'utf-8': + case 'utf-8-bom': + // TODO: Do we really care about the BOM? + $charset = 'UTF-8'; + break; + + case 'utf-16be': + $charset = 'UTF-16BE'; + break; + + case 'utf-16le': + $charset = 'UTF-16LE'; + break; + + default: + return; + } + + $data = $this->getData($path); + + if (mb_check_encoding($data, $charset)) { + return; + } + + $this->raiseLintAtPath( + self::LINT_CHARSET, + pht('Invalid characters in charset.')); + + if ($this->isMessageEnabled(self::LINT_BAD_CHARSET)) { + $this->stopAllLinters(); + } + } + + private function lintLineLength($path) { + $lines = phutil_split_lines($this->getData($path), false); + $width = $this->getEditorConfig( + $path, + PhutilEditorConfig::LINE_LENGTH, + $this->maxLineLength); + + if (!$width) { + return; + } - $width = $this->maxLineLength; foreach ($lines as $line_idx => $line) { if (strlen($line) > $width) { $this->raiseLintAtLine( @@ -165,53 +260,47 @@ } } - protected function lintEOFNewline($path) { - $data = $this->getData($path); - if (!strlen($data) || $data[strlen($data) - 1] != "\n") { - $this->raiseLintAtOffset( - strlen($data), - self::LINT_EOF_NEWLINE, - pht('Files must end in a newline.'), - '', - "\n"); + private function lintEOFNewline($path) { + $config = $this->getEditorConfig( + $path, + PhutilEditorConfig::FINAL_NEWLINE, + false); + + if (!$config) { + return; } - } - protected function lintCharset($path) { $data = $this->getData($path); - $matches = null; - $bad = '[^\x09\x0A\x20-\x7E]'; - $preg = preg_match_all( - "/{$bad}(.*{$bad})?/", + $preg = preg_match( + '/(\n|\r|\r\n)$/', $data, $matches, PREG_OFFSET_CAPTURE); - if (!$preg) { + if ($preg) { return; } - foreach ($matches[0] as $match) { - list($string, $offset) = $match; - $this->raiseLintAtOffset( - $offset, - self::LINT_BAD_CHARSET, - pht( - 'Source code should contain only ASCII bytes with ordinal '. - 'decimal values between 32 and 126 inclusive, plus linefeed. '. - 'Do not use UTF-8 or other multibyte charsets.'), - $string); - } + $this->raiseLintAtOffset( + strlen($data), + self::LINT_EOF_NEWLINE, + pht('Files must end in a newline.'), + '', + $this->getEndOfLineCharacters($path)); + } - if ($this->isMessageEnabled(self::LINT_BAD_CHARSET)) { - $this->stopAllLinters(); + private function lintTrailingWhitespace($path) { + $config = $this->getEditorConfig( + $path, + PhutilEditorConfig::TRAILING_WHITESPACE, + false); + + if (!$config) { + return; } - } - protected function lintTrailingWhitespace($path) { $data = $this->getData($path); - $matches = null; $preg = preg_match_all( '/ +$/m', @@ -237,9 +326,17 @@ } } - protected function lintBOFWhitespace($path) { - $data = $this->getData($path); + private function lintBOFWhitespace($path) { + $config = $this->getEditorConfig( + $path, + PhutilEditorConfig::TRAILING_WHITESPACE, + false); + if (!$config) { + return; + } + + $data = $this->getData($path); $matches = null; $preg = preg_match( '/^\s*\n/', @@ -262,9 +359,17 @@ ''); } - protected function lintEOFWhitespace($path) { - $data = $this->getData($path); + private function lintEOFWhitespace($path) { + $config = $this->getEditorConfig( + $path, + PhutilEditorConfig::TRAILING_WHITESPACE, + false); + + if (!$config) { + return; + } + $data = $this->getData($path); $matches = null; $preg = preg_match( '/(?<=\n)\s+$/', @@ -287,4 +392,45 @@ ''); } + /** + * Retrieve a value from the EditorConfig files. + * + * This function delegates handling of the EditorConfig file(s) to + * @{class:PhutilEditorConfigParser}. + * + * @param string The path of the file. + * @param string The name of the EditorConfig property. + * @param mixed Default value. + * @return mixed + */ + protected function getEditorConfig($path, $property, $default = null) { + if (!$this->editorconfig) { + throw new PhutilInvalidStateException('willLintPaths'); + } + + $path = $this->getEngine()->getFilePathOnDisk($path); + $value = $this->editorconfig->getProperty($path, $property); + + return coalesce($value, $default); + } + + /** + * Return the end-of-line character(s) for a given path. + * + * @param string The path to the file. + * @return string|null + */ + protected function getEndOfLineCharacters($path) { + switch ($this->getEditorConfig($path, PhutilEditorConfig::END_OF_LINE)) { + case 'lf': + return "\n"; + + case 'cr': + return "\r"; + + case 'lfcr': + return "\r\n"; + } + } + }