353 lines
11 KiB
D
353 lines
11 KiB
D
// Copyright (c) 2014, Matthew Brennan Jones <matthew.brennan.jones@gmail.com>
|
|
// Distributed under the Boost Software License, Version 1.0.
|
|
// (See accompanying file LICENSE_1_0.txt or copy at
|
|
// http://www.boost.org/LICENSE_1_0.txt)
|
|
|
|
module dscanner.analysis.helpers;
|
|
|
|
import core.exception : AssertError;
|
|
import std.stdio;
|
|
import std.string;
|
|
import std.traits;
|
|
|
|
import dparse.ast;
|
|
import dparse.lexer : tok, Token;
|
|
import dparse.rollback_allocator;
|
|
import dscanner.analysis.base;
|
|
import dscanner.analysis.config;
|
|
import dscanner.analysis.run;
|
|
import dsymbol.modulecache : ModuleCache;
|
|
import std.experimental.allocator;
|
|
import std.experimental.allocator.mallocator;
|
|
|
|
S between(S)(S value, S before, S after) if (isSomeString!S)
|
|
{
|
|
return value.after(before).before(after);
|
|
}
|
|
|
|
S before(S)(S value, S separator) if (isSomeString!S)
|
|
{
|
|
immutable i = indexOf(value, separator);
|
|
if (i == -1)
|
|
return value;
|
|
return value[0 .. i];
|
|
}
|
|
|
|
S after(S)(S value, S separator) if (isSomeString!S)
|
|
{
|
|
immutable i = indexOf(value, separator);
|
|
if (i == -1)
|
|
return "";
|
|
return value[i + separator.length .. $];
|
|
}
|
|
|
|
string getLineIndentation(scope const(Token)[] tokens, size_t line, const AutoFixFormatting formatting)
|
|
{
|
|
import std.algorithm : countUntil;
|
|
import std.array : array;
|
|
import std.range : repeat;
|
|
import std.string : lastIndexOfAny;
|
|
|
|
auto idx = tokens.countUntil!(a => a.line == line);
|
|
if (idx == -1 || tokens[idx].column <= 1 || !formatting.indentation.length)
|
|
return "";
|
|
|
|
auto indent = tokens[idx].column - 1;
|
|
if (formatting.indentation[0] == '\t')
|
|
return (cast(immutable)'\t').repeat(indent).array;
|
|
else
|
|
return (cast(immutable)' ').repeat(indent).array;
|
|
}
|
|
|
|
/**
|
|
* This assert function will analyze the passed in code, get the warnings,
|
|
* and make sure they match the warnings in the comments. Warnings are
|
|
* marked like so if range doesn't matter: // [warn]: Failed to do somethings.
|
|
*
|
|
* To test for start and end column, mark warnings as multi-line comments like
|
|
* this: /+
|
|
* ^^^^^ [warn]: Failed to do somethings. +/
|
|
*/
|
|
void assertAnalyzerWarnings(string code, const StaticAnalysisConfig config,
|
|
string file = __FILE__, size_t line = __LINE__)
|
|
{
|
|
import dscanner.analysis.run : parseModule;
|
|
import dparse.lexer : StringCache, Token;
|
|
|
|
StringCache cache = StringCache(StringCache.defaultBucketCount);
|
|
RollbackAllocator r;
|
|
const(Token)[] tokens;
|
|
const(Module) m = parseModule(file, cast(ubyte[]) code, &r, defaultErrorFormat, cache, false, tokens);
|
|
|
|
ModuleCache moduleCache;
|
|
|
|
// Run the code and get any warnings
|
|
MessageSet rawWarnings = analyze("test", m, config, moduleCache, tokens);
|
|
string[] codeLines = code.splitLines();
|
|
|
|
struct FoundWarning
|
|
{
|
|
string msg;
|
|
size_t startColumn, endColumn;
|
|
}
|
|
|
|
// Get the warnings ordered by line
|
|
FoundWarning[size_t] warnings;
|
|
foreach (rawWarning; rawWarnings[])
|
|
{
|
|
// Skip the warning if it is on line zero
|
|
immutable size_t rawLine = rawWarning.endLine;
|
|
if (rawLine == 0)
|
|
{
|
|
stderr.writefln("!!! Skipping warning because it is on line zero:\n%s",
|
|
rawWarning.message);
|
|
continue;
|
|
}
|
|
|
|
size_t warnLine = line - 1 + rawLine;
|
|
warnings[warnLine] = FoundWarning(
|
|
format("[warn]: %s", rawWarning.message),
|
|
rawWarning.startLine != rawWarning.endLine ? 1 : rawWarning.startColumn,
|
|
rawWarning.endColumn,
|
|
);
|
|
}
|
|
|
|
// Get all the messages from the comments in the code
|
|
FoundWarning[size_t] messages;
|
|
bool lastLineStartedComment = false;
|
|
foreach (i, codeLine; codeLines)
|
|
{
|
|
scope (exit)
|
|
lastLineStartedComment = codeLine.stripRight.endsWith("/+", "/*") > 0;
|
|
|
|
// Get the line of this code line
|
|
size_t lineNo = i + line;
|
|
|
|
if (codeLine.stripLeft.startsWith("^") && lastLineStartedComment)
|
|
{
|
|
auto start = codeLine.indexOf("^") + 1;
|
|
assert(start != 0);
|
|
auto end = codeLine.indexOfNeither("^", start) + 1;
|
|
assert(end != 0);
|
|
auto warn = codeLine.indexOf("[warn]:");
|
|
assert(warn != -1, "malformed line, expected `[warn]: text` after `^^^^^` part");
|
|
auto message = codeLine[warn .. $].stripRight;
|
|
if (message.endsWith("+/", "*/"))
|
|
message = message[0 .. $ - 2].stripRight;
|
|
messages[lineNo - 1] = FoundWarning(message, start, end);
|
|
}
|
|
// Skip if no [warn] comment
|
|
else if (codeLine.indexOf("// [warn]:") != -1)
|
|
{
|
|
// Skip if there is no comment or code
|
|
immutable string codePart = codeLine.before("// ");
|
|
immutable string commentPart = codeLine.after("// ");
|
|
if (!codePart.length || !commentPart.length)
|
|
continue;
|
|
|
|
// Get the message
|
|
messages[lineNo] = FoundWarning(commentPart);
|
|
}
|
|
}
|
|
|
|
// Throw an assert error if any messages are not listed in the warnings
|
|
foreach (lineNo, message; messages)
|
|
{
|
|
// No warning
|
|
if (lineNo !in warnings)
|
|
{
|
|
immutable string errors = "Expected warning:\n%s\nFrom source code at (%s:?):\n%s".format(messages[lineNo],
|
|
lineNo, codeLines[lineNo - line]);
|
|
throw new AssertError(errors, file, lineNo);
|
|
}
|
|
// Different warning
|
|
else if (warnings[lineNo].msg != messages[lineNo].msg)
|
|
{
|
|
immutable string errors = "Expected warning:\n%s\nBut was:\n%s\nFrom source code at (%s:?):\n%s".format(
|
|
messages[lineNo], warnings[lineNo], lineNo, codeLines[lineNo - line]);
|
|
throw new AssertError(errors, file, lineNo);
|
|
}
|
|
|
|
// specified column range
|
|
if ((message.startColumn || message.endColumn)
|
|
&& warnings[lineNo] != message)
|
|
{
|
|
import std.algorithm : max;
|
|
import std.array : array;
|
|
import std.range : repeat;
|
|
import std.string : replace;
|
|
|
|
const(char)[] expectedRange = ' '.repeat(max(0, cast(int)message.startColumn - 1)).array
|
|
~ '^'.repeat(max(0, cast(int)(message.endColumn - message.startColumn))).array;
|
|
const(char)[] actualRange;
|
|
if (!warnings[lineNo].startColumn || warnings[lineNo].startColumn == warnings[lineNo].endColumn)
|
|
actualRange = "no column range defined!";
|
|
else
|
|
actualRange = ' '.repeat(max(0, cast(int)warnings[lineNo].startColumn - 1)).array
|
|
~ '^'.repeat(max(0, cast(int)(warnings[lineNo].endColumn - warnings[lineNo].startColumn))).array;
|
|
size_t paddingWidth = max(expectedRange.length, actualRange.length);
|
|
immutable string errors = "Wrong warning range: expected %s, but was %s\nFrom source code at (%s:?):\n%s\n%-*s <-- expected\n%-*s <-- actual".format(
|
|
[message.startColumn, message.endColumn],
|
|
[warnings[lineNo].startColumn, warnings[lineNo].endColumn],
|
|
lineNo, codeLines[lineNo - line].replace("\t", " "),
|
|
paddingWidth, expectedRange,
|
|
paddingWidth, actualRange);
|
|
throw new AssertError(errors, file, lineNo);
|
|
}
|
|
}
|
|
|
|
// Throw an assert error if there were any warnings that were not expected
|
|
string[] unexpectedWarnings;
|
|
foreach (lineNo, warning; warnings)
|
|
{
|
|
// Unexpected warning
|
|
if (lineNo !in messages)
|
|
{
|
|
unexpectedWarnings ~= "%s\nFrom source code at (%s:?):\n%s".format(warning,
|
|
lineNo, codeLines[lineNo - line]);
|
|
}
|
|
}
|
|
if (unexpectedWarnings.length)
|
|
{
|
|
immutable string message = "Unexpected warnings:\n" ~ unexpectedWarnings.join("\n");
|
|
throw new AssertError(message, file, line);
|
|
}
|
|
}
|
|
|
|
/// EOL inside this project, for tests
|
|
private static immutable fileEol = q{
|
|
};
|
|
|
|
/**
|
|
* This assert function will analyze the passed in code, get the warnings, and
|
|
* apply all specified autofixes all at once.
|
|
*
|
|
* Indicate which autofix to apply by adding a line comment at the end of the
|
|
* line with the following content: `// fix:0`, where 0 is the index which
|
|
* autofix to apply. There may only be one diagnostic on a line with this fix
|
|
* comment. Alternatively you can also just write `// fix` to apply the only
|
|
* available suggestion.
|
|
*/
|
|
void assertAutoFix(string before, string after, const StaticAnalysisConfig config,
|
|
const AutoFixFormatting formattingConfig = AutoFixFormatting(AutoFixFormatting.BraceStyle.otbs, "\t", 4, fileEol),
|
|
string file = __FILE__, size_t line = __LINE__)
|
|
{
|
|
import dparse.lexer : StringCache, Token;
|
|
import dscanner.analysis.run : parseModule;
|
|
import std.algorithm : canFind, findSplit, map, sort;
|
|
import std.conv : to;
|
|
import std.sumtype : match;
|
|
import std.typecons : tuple, Tuple;
|
|
|
|
StringCache cache = StringCache(StringCache.defaultBucketCount);
|
|
RollbackAllocator r;
|
|
const(Token)[] tokens;
|
|
const(Module) m = parseModule(file, cast(ubyte[]) before, &r, defaultErrorFormat, cache, false, tokens);
|
|
|
|
ModuleCache moduleCache;
|
|
|
|
// Run the code and get any warnings
|
|
MessageSet rawWarnings = analyze("test", m, config, moduleCache, tokens, true, true, formattingConfig);
|
|
string[] codeLines = before.splitLines();
|
|
|
|
Tuple!(Message, int)[] toApply;
|
|
int[] applyLines;
|
|
|
|
scope (failure)
|
|
{
|
|
if (toApply.length)
|
|
stderr.writefln("Would have applied these fixes:%(\n- %s%)",
|
|
toApply.map!"a[0].autofixes[a[1]].name");
|
|
else
|
|
stderr.writeln("Did not find any fixes at all up to this point.");
|
|
stderr.writeln("Found warnings on lines: ", rawWarnings[].map!(a
|
|
=> a.endLine == 0 ? 0 : a.endLine - 1 + line));
|
|
}
|
|
|
|
foreach (rawWarning; rawWarnings[])
|
|
{
|
|
// Skip the warning if it is on line zero
|
|
immutable size_t rawLine = rawWarning.endLine;
|
|
if (rawLine == 0)
|
|
{
|
|
stderr.writefln("!!! Skipping warning because it is on line zero:\n%s",
|
|
rawWarning.message);
|
|
continue;
|
|
}
|
|
|
|
auto fixComment = codeLines[rawLine - 1].findSplit("// fix");
|
|
if (fixComment[1].length)
|
|
{
|
|
applyLines ~= cast(int)rawLine - 1;
|
|
if (fixComment[2].startsWith(":"))
|
|
{
|
|
auto i = fixComment[2][1 .. $].to!int;
|
|
assert(i >= 0, "can't use negative autofix indices");
|
|
if (i >= rawWarning.autofixes.length)
|
|
throw new AssertError("autofix index out of range, diagnostic only has %s autofixes (%s)."
|
|
.format(rawWarning.autofixes.length, rawWarning.autofixes.map!"a.name"),
|
|
file, rawLine + line);
|
|
toApply ~= tuple(rawWarning, i);
|
|
}
|
|
else
|
|
{
|
|
if (rawWarning.autofixes.length != 1)
|
|
throw new AssertError("diagnostic has %s autofixes (%s), but expected exactly one."
|
|
.format(rawWarning.autofixes.length, rawWarning.autofixes.map!"a.name"),
|
|
file, rawLine + line);
|
|
toApply ~= tuple(rawWarning, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (i, codeLine; codeLines)
|
|
{
|
|
if (!applyLines.canFind(i) && codeLine.canFind("// fix"))
|
|
throw new AssertError("Missing expected warning for autofix on line %s"
|
|
.format(i + line), file, i + line);
|
|
}
|
|
|
|
AutoFix.CodeReplacement[] replacements;
|
|
|
|
foreach_reverse (pair; toApply)
|
|
{
|
|
Message message = pair[0];
|
|
AutoFix fix = message.autofixes[pair[1]];
|
|
replacements ~= fix.expectReplacements;
|
|
}
|
|
|
|
replacements.sort!"a.range[0] < b.range[0]";
|
|
|
|
improveAutoFixWhitespace(before, replacements);
|
|
|
|
string newCode = before;
|
|
foreach_reverse (replacement; replacements)
|
|
{
|
|
newCode = newCode[0 .. replacement.range[0]] ~ replacement.newText
|
|
~ newCode[replacement.range[1] .. $];
|
|
}
|
|
|
|
if (newCode != after)
|
|
{
|
|
bool onlyWhitespaceDiffers = newCode.replace("\t", "").replace(" ", "")
|
|
== after.replace("\t", "").replace(" ", "").replace("\r", "");
|
|
|
|
string formatDisplay(string code)
|
|
{
|
|
string ret = code.lineSplitter!(KeepTerminator.yes).map!(a => "\t" ~ a).join;
|
|
if (onlyWhitespaceDiffers)
|
|
ret = ret
|
|
.replace("\r", "\x1B[2m\\r\x1B[m")
|
|
.replace("\t", "\x1B[2m→ \x1B[m")
|
|
.replace(" ", "\x1B[2m⸱\x1B[m");
|
|
return ret;
|
|
}
|
|
|
|
throw new AssertError("Applying autofix didn't yield expected results. Expected:\n"
|
|
~ formatDisplay(after)
|
|
~ "\n\nActual:\n"
|
|
~ formatDisplay(newCode),
|
|
file, line);
|
|
}
|
|
}
|