falls-into-subroutine. The code above a label ends without going anywhere and the label is one something CALLs, so execution walks into the subroutine, reaches its RET, and returns to whatever the Stack happens to hold - because nobody called, there is no caller, and it goes somewhere nobody named. It is worth a rule because the symptom is nowhere near the cause and changes with the Stack. In CosmOS's monitor it was usually a byte that does not decode, in the middle of newLine; once it was inside sbfsFormat, and the machine formatted the disk it had booted from. Two exemptions, and both had to exist or the rule would have reported well written code: A TAIL CALL IS THE SAME SHAPE AND IS FINE. Falling out of one subroutine into another means the RET returns to the outer caller, which is real. So it only fires when nothing since the last branch or return was a call target either - which is the linter's usual trade of precision for being worth reading. AND osExit NEVER RETURNS. It is how a loaded program gives the machine back, and every program here ends with it and then writes its helpers underneath. Without that, twelve well written programs were reported. It is the one name from the system this tool knows, and the comment says why it is there. Also SRET, which stopsFallthrough did not list. It returns from a handler exactly as RET returns from a call, and leaving it out is a gap in every rule that asks what reaches an instruction. Load bearing rather than tidy: without it cosmos.asm reports a handler ending in SRET as falling into the routine written under it. A first pass over the file collects call targets, because a subroutine is very often called from further down than it is written. The corpus reports none of it, which is the point rather than a disappointment, and the Test Manual now says so - a baseline entry that is absent is otherwise indistinguishable from a rule that never runs. Checked against the version of cosmos.asm from before the fix, where it names the line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
970 lines
40 KiB
C
970 lines
40 KiB
C
// Linter.c
|
|
// Small, deliberately conservative source linter for SplitBit assembly.
|
|
//
|
|
// This first version works one source file at a time and looks only at instructions
|
|
// which are adjacent in that file. It is not an assembler and does not expand
|
|
// includes or follow control flow. That modest boundary makes every diagnostic easy
|
|
// to explain while the useful rules, and the interface they need, are still being
|
|
// discovered.
|
|
|
|
#include <ctype.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include "../Assembler/assembly.h"
|
|
#include "../Emulator/cpu.h"
|
|
|
|
#define LINE_CAPACITY 1024
|
|
#define TOKEN_CAPACITY 64
|
|
#define RULE_CAPACITY 32
|
|
|
|
// Every rule this knows, named once. The count in the clean run message comes from here,
|
|
// so a rule added without a name here is a rule the summary does not know about.
|
|
static const char *const ruleNames[] = {
|
|
"redundant-setd", "redundant-assignment", "pointer-offset", "redundant-ccf",
|
|
"known-branch", "zero-load", "dead-assignment", "q-through-stack", "self-push-pop",
|
|
"branch-to-next", "unreachable", "dead-suppression", "falls-into-subroutine",
|
|
};
|
|
#define RULE_COUNT ((int)(sizeof(ruleNames) / sizeof(ruleNames[0])))
|
|
|
|
typedef struct {
|
|
char mnemonic[TOKEN_CAPACITY];
|
|
char spelling[TOKEN_CAPACITY];
|
|
char operand[TOKEN_CAPACITY];
|
|
long literal;
|
|
int hasLiteral;
|
|
int line;
|
|
} SourceInstruction;
|
|
|
|
typedef enum {
|
|
SOURCE_OTHER,
|
|
SOURCE_INSTRUCTION,
|
|
SOURCE_LABEL,
|
|
SOURCE_BOUNDARY
|
|
} SourceLine;
|
|
|
|
typedef struct {
|
|
int aKnown;
|
|
int bKnown;
|
|
uint8_t a;
|
|
uint8_t b;
|
|
} KnownRegisters;
|
|
|
|
typedef struct {
|
|
int known;
|
|
char base[TOKEN_CAPACITY];
|
|
uint16_t offset;
|
|
} KnownPointer;
|
|
|
|
typedef enum {
|
|
CARRY_UNKNOWN = -1,
|
|
CARRY_CLEAR = 0,
|
|
CARRY_SET = 1
|
|
} KnownCarry;
|
|
|
|
static void usage(const char *name) {
|
|
printf("Usage: %s [options] <sourcefile> [sourcefile ...]\n", name);
|
|
printf("\n");
|
|
printf("Checks SplitBit assembly source for correct but needlessly long forms.\n");
|
|
printf("Warnings do not fail the command unless --fatal-warnings is given.\n");
|
|
printf("\n");
|
|
printf(" --fatal-warnings Exit non-zero if anything is reported.\n");
|
|
printf(" --machine One tab separated line per warning and nothing else:\n");
|
|
printf(" file, line, rule, message, help.\n");
|
|
printf("\n");
|
|
printf("A line whose comment says \"splitlint: <reason>\" is not reported on, and the\n");
|
|
printf("reason is required, so that a deliberate exception says what makes it one.\n");
|
|
printf("\"splitlint[rule]: <reason>\" silences one rule and leaves the line honest\n");
|
|
printf("about the others. A marker that silences nothing is itself reported.\n");
|
|
printf("\n");
|
|
printf("Rules:");
|
|
for (int i = 0; i < RULE_COUNT; i++) {
|
|
printf("%s %s", i && i % 4 == 0 ? "\n " : "", ruleNames[i]);
|
|
}
|
|
printf("\n");
|
|
}
|
|
|
|
static void uppercase(char *text) {
|
|
for (; *text; text++) {
|
|
*text = (char)toupper((unsigned char)*text);
|
|
}
|
|
}
|
|
|
|
// A semicolon inside a string belongs to the string. Anything after one outside a
|
|
// string is a comment and must not accidentally look like an instruction.
|
|
static void removeComment(char *line) {
|
|
int inString = 0;
|
|
for (char *at = line; *at; at++) {
|
|
if (*at == '"') {
|
|
inString = !inString;
|
|
} else if (*at == ';' && !inString) {
|
|
*at = '\0';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
static int literalValue(const char *text, long *value) {
|
|
int base;
|
|
if (text[0] != '0' || (text[1] != 'x' && text[1] != 'X'
|
|
&& text[1] != 'd' && text[1] != 'D')) {
|
|
return 0;
|
|
}
|
|
base = (text[1] == 'x' || text[1] == 'X') ? 16 : 10;
|
|
char *end;
|
|
long parsed = strtol(text + 2, &end, base);
|
|
if (end == text + 2 || *end != '\0' || parsed < 0 || parsed > 255) {
|
|
return 0;
|
|
}
|
|
*value = parsed;
|
|
return 1;
|
|
}
|
|
|
|
// Classifies enough of a physical source line for local analysis. Directives, strings,
|
|
// literal data, and unknown tokens are boundaries: until the linter shares the full
|
|
// assembler frontend, it makes no control-flow claim across something it did not parse.
|
|
static SourceLine parseInstruction(char *line, int lineNumber, SourceInstruction *result) {
|
|
removeComment(line);
|
|
char *token = strtok(line, " \t\r\n");
|
|
if (!token) {
|
|
return SOURCE_OTHER;
|
|
}
|
|
if (token[0] == '#' || token[0] == '"') {
|
|
return SOURCE_BOUNDARY;
|
|
}
|
|
size_t length = strlen(token);
|
|
if (length && token[length - 1] == ':') {
|
|
memset(result, 0, sizeof(*result));
|
|
if (length - 1 < sizeof(result->spelling)) {
|
|
memcpy(result->spelling, token, length - 1);
|
|
result->spelling[length - 1] = '\0';
|
|
}
|
|
result->line = lineNumber;
|
|
return SOURCE_LABEL;
|
|
}
|
|
|
|
char mnemonic[TOKEN_CAPACITY];
|
|
if (length >= sizeof(mnemonic)) {
|
|
return SOURCE_BOUNDARY;
|
|
}
|
|
strcpy(mnemonic, token);
|
|
uppercase(mnemonic);
|
|
char *selector = strchr(mnemonic, '.');
|
|
if (selector) {
|
|
*selector = '\0';
|
|
}
|
|
if (getOpcode(mnemonic) == NOT_AN_OPCODE) {
|
|
return SOURCE_BOUNDARY;
|
|
}
|
|
|
|
memset(result, 0, sizeof(*result));
|
|
strcpy(result->mnemonic, mnemonic);
|
|
strncpy(result->spelling, token, sizeof(result->spelling) - 1);
|
|
result->line = lineNumber;
|
|
char *operand = strtok(NULL, " \t\r\n");
|
|
if (operand) {
|
|
strncpy(result->operand, operand, sizeof(result->operand) - 1);
|
|
result->hasLiteral = literalValue(operand, &result->literal);
|
|
}
|
|
return SOURCE_INSTRUCTION;
|
|
}
|
|
|
|
// ---- Saying that something is deliberate ----
|
|
//
|
|
// A line whose comment carries "splitlint: <reason>" is not reported on, and the reason is
|
|
// REQUIRED: a suppression with no explanation is a way to make a tool quiet rather than a
|
|
// way to say something. "splitlint[rule]: <reason>" silences one rule and leaves the line
|
|
// honest about every other, which matters once a line can trip more than one.
|
|
//
|
|
// Warnings are reported against a line that has already been read, sometimes an earlier
|
|
// one than the line in hand, so what is kept is the set of lines seen so far.
|
|
typedef struct {
|
|
int line;
|
|
char rule[RULE_CAPACITY]; // Empty for "every rule on this line".
|
|
int used;
|
|
} Suppression;
|
|
|
|
static Suppression *suppressions = NULL;
|
|
static int suppressedCount = 0;
|
|
static int suppressedRoom = 0;
|
|
static int suppressionsUsed = 0;
|
|
static int machineReadable = 0;
|
|
|
|
static void forgetSuppressions(void) {
|
|
free(suppressions);
|
|
suppressions = NULL;
|
|
suppressedCount = 0;
|
|
suppressedRoom = 0;
|
|
}
|
|
|
|
static int rememberSuppression(int line, const char *rule) {
|
|
if (suppressedCount == suppressedRoom) {
|
|
int room = suppressedRoom ? suppressedRoom * 2 : 16;
|
|
Suppression *grown = realloc(suppressions, (size_t)room * sizeof(Suppression));
|
|
if (!grown) {
|
|
return 0;
|
|
}
|
|
suppressions = grown;
|
|
suppressedRoom = room;
|
|
}
|
|
suppressions[suppressedCount].line = line;
|
|
suppressions[suppressedCount].used = 0;
|
|
snprintf(suppressions[suppressedCount].rule, RULE_CAPACITY, "%s", rule ? rule : "");
|
|
suppressedCount++;
|
|
return 1;
|
|
}
|
|
|
|
static int isSuppressed(int line, const char *rule) {
|
|
for (int i = 0; i < suppressedCount; i++) {
|
|
if (suppressions[i].line != line) {
|
|
continue;
|
|
}
|
|
if (suppressions[i].rule[0] != '\0'
|
|
&& strcmp(suppressions[i].rule, rule) != 0) {
|
|
continue; // Names a different rule, so this one still applies.
|
|
}
|
|
suppressions[i].used = 1;
|
|
suppressionsUsed++;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// A suppression that no longer suppresses anything is the thing the required reason was
|
|
// meant to prevent: an exception that outlived whatever made it necessary. Saying so is
|
|
// what keeps the count of them meaningful.
|
|
static int reportDeadSuppressions(const char *path) {
|
|
int dead = 0;
|
|
for (int i = 0; i < suppressedCount; i++) {
|
|
if (suppressions[i].used) {
|
|
continue;
|
|
}
|
|
if (machineReadable) {
|
|
printf("%s\t%d\t%s\t%s\t%s\n", path, suppressions[i].line,
|
|
"dead-suppression", "this line no longer has a warning to suppress",
|
|
"remove the splitlint comment");
|
|
} else {
|
|
printf("%s:%d: style: this line no longer has a warning to suppress"
|
|
" [dead-suppression]\n", path, suppressions[i].line);
|
|
printf(" help: remove the splitlint comment\n");
|
|
}
|
|
dead++;
|
|
}
|
|
return dead;
|
|
}
|
|
|
|
// The reason has to be there and has to say something. A bare marker is refused rather
|
|
// than honoured, because a suppression nobody explained is the one that outlives whatever
|
|
// made it necessary.
|
|
//
|
|
// Returns 1 if the line is marked, 0 if it is not, and -1 if it is marked with nothing.
|
|
// A rule name in brackets, if there is one, is copied into `rule`.
|
|
static int suppressionOn(const char *rawLine, char *rule) {
|
|
rule[0] = '\0';
|
|
const char *at = strstr(rawLine, "splitlint");
|
|
if (!at) {
|
|
return 0;
|
|
}
|
|
at += strlen("splitlint");
|
|
if (*at == '[') {
|
|
const char *close = strchr(at, ']');
|
|
if (!close || close == at + 1 || (size_t)(close - at - 1) >= RULE_CAPACITY) {
|
|
return -1;
|
|
}
|
|
memcpy(rule, at + 1, (size_t)(close - at - 1));
|
|
rule[close - at - 1] = '\0';
|
|
at = close + 1;
|
|
}
|
|
if (*at != ':') {
|
|
return 0; // "splitlint" inside ordinary prose is not a marker.
|
|
}
|
|
at++;
|
|
while (*at == ' ' || *at == '\t') {
|
|
at++;
|
|
}
|
|
return (*at == '\0' || *at == '\n' || *at == '\r') ? -1 : 1;
|
|
}
|
|
|
|
static int warning(const char *path, const char *rule, int line, const char *message,
|
|
const char *help) {
|
|
if (isSuppressed(line, rule)) {
|
|
return 0;
|
|
}
|
|
if (machineReadable) {
|
|
// One line, tab separated, so nothing downstream has to read prose. This file's
|
|
// own diagnostics were parsed with regular expressions three times in a day
|
|
// before it had a shape anything could rely on.
|
|
printf("%s\t%d\t%s\t%s\t%s\n", path, line, rule, message, help);
|
|
return 1;
|
|
}
|
|
// The rule goes in brackets at the end, the way a compiler names the flag that
|
|
// produced a warning, so that the sentence still reads as a sentence.
|
|
printf("%s:%d: style: %s [%s]\n", path, line, message, rule);
|
|
printf(" help: %s\n", help);
|
|
return 1;
|
|
}
|
|
|
|
// Assignments whose only architectural effect is replacing one operand register. INA,
|
|
// INB and POP are intentionally absent: even when their result is overwritten, removing
|
|
// them would leave a byte unread from a device or an entry unconsumed from the stack.
|
|
static char assignedRegister(const SourceInstruction *instruction) {
|
|
static const char *aWriters[] = { "RSTA", "INIA", "MVQA", "LDA" };
|
|
static const char *bWriters[] = { "RSTB", "INIB", "MVQB", "LDB" };
|
|
for (size_t i = 0; i < sizeof(aWriters) / sizeof(aWriters[0]); i++) {
|
|
if (strcmp(instruction->mnemonic, aWriters[i]) == 0) {
|
|
return 'A';
|
|
}
|
|
}
|
|
for (size_t i = 0; i < sizeof(bWriters) / sizeof(bWriters[0]); i++) {
|
|
if (strcmp(instruction->mnemonic, bWriters[i]) == 0) {
|
|
return 'B';
|
|
}
|
|
}
|
|
return '\0';
|
|
}
|
|
|
|
static int stopsFallthrough(const SourceInstruction *instruction) {
|
|
return strcmp(instruction->mnemonic, "BRI") == 0
|
|
|| strcmp(instruction->mnemonic, "BRD") == 0
|
|
|| strcmp(instruction->mnemonic, "RET") == 0
|
|
|| strcmp(instruction->mnemonic, "RRET") == 0
|
|
|| strcmp(instruction->mnemonic, "RETI") == 0
|
|
// SRET was missing here, which is a gap in every rule that asks what reaches an
|
|
// instruction: it returns from a handler exactly as RET returns from a call.
|
|
|| strcmp(instruction->mnemonic, "SRET") == 0
|
|
|| strcmp(instruction->mnemonic, "HALT") == 0
|
|
// ---- The one trap that does not come back ----
|
|
//
|
|
// A SWI is a call and almost every one of them returns, so a SWI does not end a run
|
|
// of code. osExit is the exception and the exception matters: it is how a loaded
|
|
// program gives the machine back, so nothing after it runs, and every program on
|
|
// this machine ends with it and then writes its helper routines underneath.
|
|
//
|
|
// NAMED RATHER THAN NUMBERED, because that is what the source says. This is the only
|
|
// name from the system that this tool knows, and it is here because without it the
|
|
// rule below would report every well written program in the repository.
|
|
|| (strcmp(instruction->mnemonic, "SWI") == 0
|
|
&& strcmp(instruction->operand, "osExit") == 0);
|
|
}
|
|
|
|
// ---- Which labels are entered by a CALL ----
|
|
//
|
|
// Collected in a pass of its own before anything is judged, because the rule below asks a
|
|
// question about a label that cannot be answered until the whole file has been read: a
|
|
// subroutine is very often called from further down than it is written.
|
|
#define CALL_TARGET_CAPACITY 2048
|
|
static char callTargets[CALL_TARGET_CAPACITY][TOKEN_CAPACITY];
|
|
static int callTargetCount = 0;
|
|
|
|
static int isCallTarget(const char *name) {
|
|
for (int i = 0; i < callTargetCount; i++) {
|
|
if (strcmp(callTargets[i], name) == 0) {
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Reads the file once for nothing but CALL and RCAL operands. Answers 0 if there were more
|
|
// distinct ones than there is room for, which is reported rather than quietly truncated: a
|
|
// rule that silently forgot half its input would go quiet instead of wrong, which is worse.
|
|
static int collectCallTargets(const char *path) {
|
|
callTargetCount = 0;
|
|
FILE *file = fopen(path, "r");
|
|
if (!file) {
|
|
return 1; // The caller opens it again and reports the failure properly.
|
|
}
|
|
char line[LINE_CAPACITY];
|
|
int lineNumber = 0;
|
|
while (fgets(line, sizeof(line), file)) {
|
|
lineNumber++;
|
|
SourceInstruction current;
|
|
if (parseInstruction(line, lineNumber, ¤t) != SOURCE_INSTRUCTION) {
|
|
continue;
|
|
}
|
|
if (strcmp(current.mnemonic, "CALL") != 0 && strcmp(current.mnemonic, "RCAL") != 0) {
|
|
continue;
|
|
}
|
|
if (!current.operand[0] || isCallTarget(current.operand)) {
|
|
continue;
|
|
}
|
|
if (callTargetCount >= CALL_TARGET_CAPACITY) {
|
|
fclose(file);
|
|
fprintf(stderr, "%s: error: more than %d distinct call targets\n",
|
|
path, CALL_TARGET_CAPACITY);
|
|
return 0;
|
|
}
|
|
snprintf(callTargets[callTargetCount], TOKEN_CAPACITY, "%s", current.operand);
|
|
callTargetCount++;
|
|
}
|
|
fclose(file);
|
|
return 1;
|
|
}
|
|
|
|
static int isDirectBranch(const SourceInstruction *instruction) {
|
|
static const char *branches[] = {
|
|
"BRI", "BRQ", "BRA", "BRB", "BRC", "BNQ", "BNA", "BNB", "BNC"
|
|
};
|
|
for (size_t i = 0; i < sizeof(branches) / sizeof(branches[0]); i++) {
|
|
if (strcmp(instruction->mnemonic, branches[i]) == 0) {
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void forgetRegisters(KnownRegisters *known) {
|
|
memset(known, 0, sizeof(*known));
|
|
}
|
|
|
|
static const char *selectorSuffix(const SourceInstruction *instruction) {
|
|
const char *suffix = strchr(instruction->spelling, '.');
|
|
return suffix ? suffix : "";
|
|
}
|
|
|
|
static int selectedPointer(const SourceInstruction *instruction) {
|
|
const char *selector = strchr(instruction->spelling, '.');
|
|
if (!selector) {
|
|
return 0;
|
|
}
|
|
char *end;
|
|
long selected = strtol(selector + 1, &end, 10);
|
|
if (end == selector + 1 || selected < 0 || selected >= DATA_POINTERS) {
|
|
return 0;
|
|
}
|
|
return (int)selected;
|
|
}
|
|
|
|
static void forgetPointers(KnownPointer pointers[DATA_POINTERS]) {
|
|
memset(pointers, 0, sizeof(*pointers) * DATA_POINTERS);
|
|
}
|
|
|
|
static int lintKnownPointers(const char *path, const SourceInstruction *instruction,
|
|
const KnownPointer pointers[DATA_POINTERS]) {
|
|
if (strcmp(instruction->mnemonic, "SETD") != 0 || !instruction->operand[0]) {
|
|
return 0;
|
|
}
|
|
int selected = selectedPointer(instruction);
|
|
const KnownPointer *pointer = &pointers[selected];
|
|
if (!pointer->known || pointer->offset != 0
|
|
|| strcmp(pointer->base, instruction->operand) != 0) {
|
|
return 0;
|
|
}
|
|
char message[180];
|
|
snprintf(message, sizeof(message), "DP%d is already known to hold %s",
|
|
selected, instruction->operand);
|
|
return warning(path, "redundant-setd", instruction->line, message, "remove the redundant SETD");
|
|
}
|
|
|
|
static void moveKnownPointer(KnownPointer *pointer, uint16_t amount, int downward) {
|
|
if (!pointer->known) {
|
|
return;
|
|
}
|
|
pointer->offset = downward ? (uint16_t)(pointer->offset - amount)
|
|
: (uint16_t)(pointer->offset + amount);
|
|
}
|
|
|
|
static void updateKnownPointers(const SourceInstruction *instruction,
|
|
const KnownRegisters *registers,
|
|
KnownPointer pointers[DATA_POINTERS]) {
|
|
int selected = selectedPointer(instruction);
|
|
KnownPointer *pointer = &pointers[selected];
|
|
|
|
if (strcmp(instruction->mnemonic, "SETD") == 0) {
|
|
if (instruction->operand[0]) {
|
|
pointer->known = 1;
|
|
strncpy(pointer->base, instruction->operand, sizeof(pointer->base) - 1);
|
|
pointer->base[sizeof(pointer->base) - 1] = '\0';
|
|
pointer->offset = 0;
|
|
} else {
|
|
pointer->known = 0;
|
|
}
|
|
} else if (strcmp(instruction->mnemonic, "INCD") == 0) {
|
|
moveKnownPointer(pointer, 1, 0);
|
|
} else if (strcmp(instruction->mnemonic, "DECD") == 0) {
|
|
moveKnownPointer(pointer, 1, 1);
|
|
} else if (strcmp(instruction->mnemonic, "DPUP") == 0 && instruction->hasLiteral) {
|
|
moveKnownPointer(pointer, (uint16_t)instruction->literal, 0);
|
|
} else if (strcmp(instruction->mnemonic, "DPDN") == 0 && instruction->hasLiteral) {
|
|
moveKnownPointer(pointer, (uint16_t)instruction->literal, 1);
|
|
} else if (strcmp(instruction->mnemonic, "DPUA") == 0 && registers->aKnown) {
|
|
moveKnownPointer(pointer, registers->a, 0);
|
|
} else if (strcmp(instruction->mnemonic, "DPDA") == 0 && registers->aKnown) {
|
|
moveKnownPointer(pointer, registers->a, 1);
|
|
} else if (strcmp(instruction->mnemonic, "DPUW") == 0
|
|
&& registers->aKnown && registers->bKnown) {
|
|
moveKnownPointer(pointer, ((uint16_t)registers->a << 8) | registers->b, 0);
|
|
} else if (strcmp(instruction->mnemonic, "DPDW") == 0
|
|
&& registers->aKnown && registers->bKnown) {
|
|
moveKnownPointer(pointer, ((uint16_t)registers->a << 8) | registers->b, 1);
|
|
} else if (strcmp(instruction->mnemonic, "LDD") == 0
|
|
|| strcmp(instruction->mnemonic, "POPD") == 0
|
|
|| strcmp(instruction->mnemonic, "MVSD") == 0) {
|
|
pointer->known = 0;
|
|
}
|
|
|
|
// ---- A CALL ends every claim, and that is a deliberate loss of precision ----
|
|
//
|
|
// CALL really does save and restore A, B and Data Pointers 0 to 2, so a pointer set
|
|
// before one is genuinely still set after it, and this used to say so - forgetting
|
|
// only DP3, which CALL does not restore. It was right, and the advice it produced was
|
|
// not safe to take.
|
|
//
|
|
// 122 of the 178 redundant SETDs it found across the corpus were redundant ONLY
|
|
// because of that restore. Removing them is correct today and becomes a wrong-pointer
|
|
// bug the moment the callee is converted from CALL to RCAL - which is not a
|
|
// hypothetical, it is what RCAL was added to this machine for, and converting the hot
|
|
// helpers was measured at close to halving the assembler's memory traffic. Worse, the
|
|
// linter would go quiet rather than complain: it forgets everything across an RCAL, so
|
|
// it would simply stop reporting while the removals stayed removed.
|
|
//
|
|
// So a claim now ends at any call, the way a claim about carry already did. Fifty
|
|
// four recommendations that stay true are worth more than a hundred and seventy eight
|
|
// that are conditional on a change the project intends to make.
|
|
if (strcmp(instruction->mnemonic, "CALL") == 0
|
|
|| strcmp(instruction->mnemonic, "RCAL") == 0
|
|
|| strcmp(instruction->mnemonic, "SWI") == 0) {
|
|
forgetPointers(pointers);
|
|
}
|
|
}
|
|
|
|
static int lintKnownRegisters(const char *path, const SourceInstruction *instruction,
|
|
const SourceInstruction *previous,
|
|
const KnownRegisters *known) {
|
|
int warnings = 0;
|
|
char message[180];
|
|
char help[160];
|
|
char previousAssignment = assignedRegister(previous);
|
|
|
|
if (known->aKnown && previousAssignment != 'A'
|
|
&& ((strcmp(instruction->mnemonic, "RSTA") == 0 && known->a == 0)
|
|
|| (strcmp(instruction->mnemonic, "INIA") == 0
|
|
&& instruction->hasLiteral && known->a == instruction->literal))) {
|
|
snprintf(message, sizeof(message), "%s leaves A at its known value of %u",
|
|
instruction->spelling, known->a);
|
|
warnings += warning(path, "redundant-assignment", instruction->line, message, "remove the redundant assignment");
|
|
}
|
|
if (known->bKnown && previousAssignment != 'B'
|
|
&& ((strcmp(instruction->mnemonic, "RSTB") == 0 && known->b == 0)
|
|
|| (strcmp(instruction->mnemonic, "INIB") == 0
|
|
&& instruction->hasLiteral && known->b == instruction->literal))) {
|
|
snprintf(message, sizeof(message), "%s leaves B at its known value of %u",
|
|
instruction->spelling, known->b);
|
|
warnings += warning(path, "redundant-assignment", instruction->line, message, "remove the redundant assignment");
|
|
}
|
|
|
|
if (known->aKnown && (strcmp(instruction->mnemonic, "DPUA") == 0
|
|
|| strcmp(instruction->mnemonic, "DPDA") == 0)) {
|
|
if (known->a == 0) {
|
|
warnings += warning(path, "pointer-offset", instruction->line, "pointer offset is known to be zero",
|
|
"remove the pointer instruction");
|
|
} else if (known->a == 1) {
|
|
snprintf(help, sizeof(help), "use %s%s",
|
|
strcmp(instruction->mnemonic, "DPUA") == 0 ? "INCD" : "DECD",
|
|
selectorSuffix(instruction));
|
|
warnings += warning(path, "pointer-offset", instruction->line, "pointer offset is known to be one", help);
|
|
}
|
|
}
|
|
|
|
if (known->aKnown && known->bKnown
|
|
&& (strcmp(instruction->mnemonic, "DPUW") == 0
|
|
|| strcmp(instruction->mnemonic, "DPDW") == 0)) {
|
|
uint16_t word = ((uint16_t)known->a << 8) | known->b;
|
|
if (word == 0) {
|
|
warnings += warning(path, "pointer-offset", instruction->line, "word-sized pointer offset is known to be zero",
|
|
"remove the pointer instruction");
|
|
} else if (word == 1) {
|
|
snprintf(help, sizeof(help), "use %s%s",
|
|
strcmp(instruction->mnemonic, "DPUW") == 0 ? "INCD" : "DECD",
|
|
selectorSuffix(instruction));
|
|
warnings += warning(path, "pointer-offset", instruction->line, "word-sized pointer offset is known to be one", help);
|
|
}
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
static void updateKnownRegisters(const SourceInstruction *instruction,
|
|
KnownRegisters *known) {
|
|
if (strcmp(instruction->mnemonic, "SHL") == 0
|
|
|| strcmp(instruction->mnemonic, "SHR") == 0) {
|
|
if (known->aKnown && known->bKnown) {
|
|
uint16_t word = ((uint16_t)known->a << 8) | known->b;
|
|
if (strcmp(instruction->mnemonic, "SHL") == 0) {
|
|
word = (uint16_t)((word << 1) | (word >> 15));
|
|
} else {
|
|
word = (uint16_t)((word >> 1) | (word << 15));
|
|
}
|
|
known->a = (uint8_t)(word >> 8);
|
|
known->b = (uint8_t)word;
|
|
} else {
|
|
known->aKnown = 0;
|
|
known->bKnown = 0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (strcmp(instruction->mnemonic, "RSTA") == 0) {
|
|
known->aKnown = 1;
|
|
known->a = 0;
|
|
} else if (strcmp(instruction->mnemonic, "INIA") == 0 && instruction->hasLiteral) {
|
|
known->aKnown = 1;
|
|
known->a = (uint8_t)instruction->literal;
|
|
} else if (strcmp(instruction->mnemonic, "INCA") == 0) {
|
|
if (known->aKnown) known->a++;
|
|
} else if (strcmp(instruction->mnemonic, "DECA") == 0) {
|
|
if (known->aKnown) known->a--;
|
|
} else if (strcmp(instruction->mnemonic, "MVQA") == 0
|
|
|| strcmp(instruction->mnemonic, "LDA") == 0
|
|
|| strcmp(instruction->mnemonic, "POPA") == 0
|
|
|| strcmp(instruction->mnemonic, "INA") == 0) {
|
|
known->aKnown = 0;
|
|
}
|
|
|
|
if (strcmp(instruction->mnemonic, "RSTB") == 0) {
|
|
known->bKnown = 1;
|
|
known->b = 0;
|
|
} else if (strcmp(instruction->mnemonic, "INIB") == 0 && instruction->hasLiteral) {
|
|
known->bKnown = 1;
|
|
known->b = (uint8_t)instruction->literal;
|
|
} else if (strcmp(instruction->mnemonic, "INCB") == 0) {
|
|
if (known->bKnown) known->b++;
|
|
} else if (strcmp(instruction->mnemonic, "DECB") == 0) {
|
|
if (known->bKnown) known->b--;
|
|
} else if (strcmp(instruction->mnemonic, "MVQB") == 0
|
|
|| strcmp(instruction->mnemonic, "LDB") == 0
|
|
|| strcmp(instruction->mnemonic, "POPB") == 0
|
|
|| strcmp(instruction->mnemonic, "INB") == 0) {
|
|
known->bKnown = 0;
|
|
}
|
|
|
|
// A call ends what is known about A and B for the same reason it ends what is known
|
|
// about a pointer: CALL's convention saves them, so the knowledge is real, but it is
|
|
// knowledge about the CALLEE rather than about the code in hand - and it stops being
|
|
// true the day that callee is reached with RCAL instead. See updateKnownPointers.
|
|
if (strcmp(instruction->mnemonic, "CALL") == 0
|
|
|| strcmp(instruction->mnemonic, "RCAL") == 0
|
|
|| strcmp(instruction->mnemonic, "SWI") == 0) {
|
|
forgetRegisters(known);
|
|
}
|
|
}
|
|
|
|
static int lintKnownCarry(const char *path, const SourceInstruction *instruction,
|
|
KnownCarry carry) {
|
|
if (carry == CARRY_UNKNOWN) {
|
|
return 0;
|
|
}
|
|
if (strcmp(instruction->mnemonic, "CCF") == 0 && carry == CARRY_CLEAR) {
|
|
return warning(path, "redundant-ccf", instruction->line, "carry is already known to be clear",
|
|
"remove the redundant CCF");
|
|
}
|
|
if (strcmp(instruction->mnemonic, "BRC") == 0) {
|
|
if (carry == CARRY_SET) {
|
|
return warning(path, "known-branch", instruction->line, "BRC is always taken because carry is known set",
|
|
"use BRI");
|
|
}
|
|
return warning(path, "known-branch", instruction->line, "BRC is never taken because carry is known clear",
|
|
"remove the branch");
|
|
}
|
|
if (strcmp(instruction->mnemonic, "BNC") == 0) {
|
|
if (carry == CARRY_CLEAR) {
|
|
return warning(path, "known-branch", instruction->line, "BNC is always taken because carry is known clear",
|
|
"use BRI");
|
|
}
|
|
return warning(path, "known-branch", instruction->line, "BNC is never taken because carry is known set",
|
|
"remove the branch");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void updateKnownCarry(const SourceInstruction *instruction,
|
|
const KnownRegisters *registers,
|
|
KnownCarry *carry) {
|
|
if (strcmp(instruction->mnemonic, "CCF") == 0) {
|
|
*carry = CARRY_CLEAR;
|
|
} else if (strcmp(instruction->mnemonic, "INCA") == 0) {
|
|
*carry = registers->aKnown ? (registers->a == 0xFF ? CARRY_SET : CARRY_CLEAR)
|
|
: CARRY_UNKNOWN;
|
|
} else if (strcmp(instruction->mnemonic, "INCB") == 0) {
|
|
*carry = registers->bKnown ? (registers->b == 0xFF ? CARRY_SET : CARRY_CLEAR)
|
|
: CARRY_UNKNOWN;
|
|
} else if (strcmp(instruction->mnemonic, "DECA") == 0) {
|
|
*carry = registers->aKnown ? (registers->a == 0 ? CARRY_SET : CARRY_CLEAR)
|
|
: CARRY_UNKNOWN;
|
|
} else if (strcmp(instruction->mnemonic, "DECB") == 0) {
|
|
*carry = registers->bKnown ? (registers->b == 0 ? CARRY_SET : CARRY_CLEAR)
|
|
: CARRY_UNKNOWN;
|
|
} else if (strcmp(instruction->mnemonic, "ADD") == 0) {
|
|
if (registers->aKnown && registers->bKnown && *carry != CARRY_UNKNOWN) {
|
|
unsigned result = registers->a + registers->b + (unsigned)*carry;
|
|
*carry = result > 255 ? CARRY_SET : CARRY_CLEAR;
|
|
} else {
|
|
*carry = CARRY_UNKNOWN;
|
|
}
|
|
} else if (strcmp(instruction->mnemonic, "SUB") == 0) {
|
|
if (registers->aKnown && registers->bKnown && *carry != CARRY_UNKNOWN) {
|
|
int result = (int)registers->a - (int)registers->b - (int)*carry;
|
|
*carry = result < 0 ? CARRY_SET : CARRY_CLEAR;
|
|
} else {
|
|
*carry = CARRY_UNKNOWN;
|
|
}
|
|
} else if (strcmp(instruction->mnemonic, "CALL") == 0
|
|
|| strcmp(instruction->mnemonic, "RCAL") == 0
|
|
|| strcmp(instruction->mnemonic, "SWI") == 0
|
|
|| strcmp(instruction->mnemonic, "RET") == 0
|
|
|| strcmp(instruction->mnemonic, "RETI") == 0) {
|
|
*carry = CARRY_UNKNOWN;
|
|
}
|
|
}
|
|
|
|
static int lintInstruction(const char *path, const SourceInstruction *previous,
|
|
const SourceInstruction *current) {
|
|
int warnings = 0;
|
|
|
|
if (current->hasLiteral && current->literal == 0
|
|
&& strcmp(current->mnemonic, "INIA") == 0) {
|
|
warnings += warning(path, "zero-load", current->line, "loading zero into A takes two bytes", "use RSTA");
|
|
} else if (current->hasLiteral && current->literal == 0
|
|
&& strcmp(current->mnemonic, "INIB") == 0) {
|
|
warnings += warning(path, "zero-load", current->line, "loading zero into B takes two bytes", "use RSTB");
|
|
}
|
|
|
|
char previousAssignment = assignedRegister(previous);
|
|
if (previousAssignment && previousAssignment == assignedRegister(current)) {
|
|
char message[180];
|
|
snprintf(message, sizeof(message), "%s assigns %c, but %s replaces it immediately",
|
|
previous->spelling, previousAssignment, current->spelling);
|
|
warnings += warning(path, "dead-assignment", previous->line, message, "remove the first assignment");
|
|
}
|
|
|
|
// Q already has direct moves to both operand registers. The older stack transfer
|
|
// idiom does the same job, but costs a write, a read, and an extra instruction. This
|
|
// is precisely the kind of sequence the linter exists to find in code written before
|
|
// the newer instruction was available.
|
|
if (strcmp(previous->mnemonic, "PSHQ") == 0
|
|
&& strcmp(current->mnemonic, "POPA") == 0) {
|
|
warnings += warning(path, "q-through-stack", previous->line, "moving Q to A through the stack takes two instructions",
|
|
"use MVQA");
|
|
} else if (strcmp(previous->mnemonic, "PSHQ") == 0
|
|
&& strcmp(current->mnemonic, "POPB") == 0) {
|
|
warnings += warning(path, "q-through-stack", previous->line, "moving Q to B through the stack takes two instructions",
|
|
"use MVQB");
|
|
} else if (strcmp(previous->mnemonic, "PSHA") == 0
|
|
&& strcmp(current->mnemonic, "POPA") == 0) {
|
|
warnings += warning(path, "self-push-pop", previous->line, "pushing A and immediately restoring it leaves A unchanged",
|
|
"remove both instructions if the stack write is not intentional");
|
|
} else if (strcmp(previous->mnemonic, "PSHB") == 0
|
|
&& strcmp(current->mnemonic, "POPB") == 0) {
|
|
warnings += warning(path, "self-push-pop", previous->line, "pushing B and immediately restoring it leaves B unchanged",
|
|
"remove both instructions if the stack write is not intentional");
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
static int lintFile(const char *path) {
|
|
FILE *file = fopen(path, "r");
|
|
if (!file) {
|
|
fprintf(stderr, "%s: error: could not open source file\n", path);
|
|
return -1;
|
|
}
|
|
|
|
if (!collectCallTargets(path)) {
|
|
fclose(file);
|
|
return -1;
|
|
}
|
|
|
|
char line[LINE_CAPACITY];
|
|
int lineNumber = 0;
|
|
int warnings = 0;
|
|
int havePrevious = 0;
|
|
int fallthroughStopped = 0;
|
|
int havePendingBranch = 0;
|
|
// Whether anything at all has been read since the last #Program or #Data, and whether
|
|
// the run of code we are in began at a label something calls. See the rule below.
|
|
int sawInstruction = 0;
|
|
int runHasCallTarget = 0;
|
|
SourceInstruction pendingBranch;
|
|
KnownRegisters known = {0};
|
|
KnownPointer pointers[DATA_POINTERS] = {0};
|
|
KnownCarry carry = CARRY_UNKNOWN;
|
|
SourceInstruction previous;
|
|
forgetSuppressions();
|
|
while (fgets(line, sizeof(line), file)) {
|
|
lineNumber++;
|
|
// Refuse to silently analyze only the first part of an unusually long line.
|
|
if (!strchr(line, '\n') && !feof(file)) {
|
|
fprintf(stderr, "%s:%d: error: line is longer than %d characters\n",
|
|
path, lineNumber, LINE_CAPACITY - 2);
|
|
fclose(file);
|
|
return -1;
|
|
}
|
|
// BEFORE THE COMMENT IS STRIPPED, because the marker lives in one. Recorded for
|
|
// the line rather than acted on here: a warning is reported against a line that
|
|
// has already been read, and not always the one in hand.
|
|
char suppressedRule[RULE_CAPACITY];
|
|
int marked = suppressionOn(line, suppressedRule);
|
|
if (marked < 0) {
|
|
fprintf(stderr, "%s:%d: error: splitlint: needs a reason after it\n",
|
|
path, lineNumber);
|
|
fclose(file);
|
|
forgetSuppressions();
|
|
return -1;
|
|
}
|
|
if (marked && !rememberSuppression(lineNumber, suppressedRule)) {
|
|
fprintf(stderr, "%s: error: out of memory recording a suppression\n", path);
|
|
fclose(file);
|
|
forgetSuppressions();
|
|
return -1;
|
|
}
|
|
|
|
SourceInstruction current;
|
|
SourceLine sourceLine = parseInstruction(line, lineNumber, ¤t);
|
|
if (sourceLine != SOURCE_INSTRUCTION) {
|
|
havePrevious = 0;
|
|
if (sourceLine == SOURCE_LABEL) {
|
|
if (havePendingBranch && current.spelling[0]
|
|
&& strcmp(pendingBranch.operand, current.spelling) == 0) {
|
|
warnings += warning(path, "branch-to-next", pendingBranch.line,
|
|
"branch target is the next labeled address",
|
|
"remove the branch");
|
|
havePendingBranch = 0;
|
|
}
|
|
// ---- Walking into a subroutine instead of calling it ----
|
|
//
|
|
// The code above ends without going anywhere, and the label below is one
|
|
// something CALLs. So execution walks into the subroutine, reaches its RET,
|
|
// and returns to whatever the Stack happens to hold - which is not a caller,
|
|
// because nobody called. It goes somewhere nobody named.
|
|
//
|
|
// THAT IS WHAT FORMATTED A DISK. CosmOS's monitor had no branch at the end
|
|
// of its command list, so an unrecognised word walked into sayPrompt; the
|
|
// RET at the bottom of it went to whatever was on the Stack, and one of the
|
|
// places that turned out to be was inside the filesystem's format routine.
|
|
// The symptom is nowhere near the cause and changes with the Stack, which is
|
|
// exactly the kind of fault worth spending a rule on.
|
|
//
|
|
// UNLESS THE RUN WE ARE IN WAS ITSELF CALLED. Falling out of one subroutine
|
|
// into another is an ordinary tail call: the RET returns to the outer
|
|
// caller, which is real and is what the author meant. So this only fires
|
|
// when nothing since the last RET or branch was a call target either.
|
|
if (!fallthroughStopped && sawInstruction && !runHasCallTarget
|
|
&& current.spelling[0] && isCallTarget(current.spelling)) {
|
|
warnings += warning(path, "falls-into-subroutine", current.line,
|
|
"execution walks into a subroutine nothing here called",
|
|
"branch past it, or end the code above with a branch or a return");
|
|
}
|
|
if (current.spelling[0] && isCallTarget(current.spelling)) {
|
|
runHasCallTarget = 1;
|
|
}
|
|
fallthroughStopped = 0;
|
|
forgetRegisters(&known);
|
|
forgetPointers(pointers);
|
|
carry = CARRY_UNKNOWN;
|
|
} else if (sourceLine == SOURCE_BOUNDARY) {
|
|
fallthroughStopped = 0;
|
|
havePendingBranch = 0;
|
|
sawInstruction = 0;
|
|
runHasCallTarget = 0;
|
|
forgetRegisters(&known);
|
|
forgetPointers(pointers);
|
|
carry = CARRY_UNKNOWN;
|
|
}
|
|
continue;
|
|
}
|
|
// Reaching another instruction before the target label makes this something
|
|
// other than a branch to the next address.
|
|
havePendingBranch = 0;
|
|
if (fallthroughStopped) {
|
|
warnings += warning(path, "unreachable", current.line,
|
|
"no ordinary fallthrough reaches this instruction",
|
|
"remove it, or give an intentional indirect entry point a label");
|
|
}
|
|
SourceInstruction empty = {0};
|
|
warnings += lintKnownRegisters(path, ¤t,
|
|
havePrevious ? &previous : &empty, &known);
|
|
warnings += lintKnownPointers(path, ¤t, pointers);
|
|
warnings += lintKnownCarry(path, ¤t, carry);
|
|
warnings += lintInstruction(path, havePrevious ? &previous : &empty, ¤t);
|
|
sawInstruction = 1;
|
|
if (stopsFallthrough(¤t)) {
|
|
fallthroughStopped = 1;
|
|
// A new run of code starts after this, and it has not been called yet.
|
|
runHasCallTarget = 0;
|
|
}
|
|
if (isDirectBranch(¤t) && current.operand[0]) {
|
|
pendingBranch = current;
|
|
havePendingBranch = 1;
|
|
}
|
|
updateKnownPointers(¤t, &known, pointers);
|
|
updateKnownCarry(¤t, &known, &carry);
|
|
updateKnownRegisters(¤t, &known);
|
|
previous = current;
|
|
havePrevious = 1;
|
|
}
|
|
if (ferror(file)) {
|
|
fprintf(stderr, "%s: error: could not read source file\n", path);
|
|
fclose(file);
|
|
forgetSuppressions();
|
|
return -1;
|
|
}
|
|
fclose(file);
|
|
warnings += reportDeadSuppressions(path);
|
|
forgetSuppressions();
|
|
return warnings;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int fatalWarnings = 0;
|
|
int firstFile = 1;
|
|
while (firstFile < argc && argv[firstFile][0] == '-' && argv[firstFile][1] == '-') {
|
|
if (strcmp(argv[firstFile], "--fatal-warnings") == 0) {
|
|
fatalWarnings = 1;
|
|
} else if (strcmp(argv[firstFile], "--machine") == 0) {
|
|
machineReadable = 1;
|
|
} else if (strcmp(argv[firstFile], "--help") == 0) {
|
|
usage(argv[0]);
|
|
return 0;
|
|
} else {
|
|
fprintf(stderr, "%s: error: unknown option \"%s\"\n", argv[0], argv[firstFile]);
|
|
return 2;
|
|
}
|
|
firstFile++;
|
|
}
|
|
if (argc <= firstFile || strcmp(argv[firstFile], "-h") == 0) {
|
|
usage(argv[0]);
|
|
return argc <= firstFile ? 1 : 0;
|
|
}
|
|
|
|
int warnings = 0;
|
|
int files = 0;
|
|
for (int i = firstFile; i < argc; i++) {
|
|
int found = lintFile(argv[i]);
|
|
if (found < 0) {
|
|
return 2;
|
|
}
|
|
warnings += found;
|
|
files++;
|
|
}
|
|
// Machine readable output is warnings and nothing else, so that a reader can take
|
|
// every line the same way rather than having to know which ones are prose.
|
|
if (!machineReadable) {
|
|
if (warnings) {
|
|
printf("%d style warning%s found.\n", warnings, warnings == 1 ? "" : "s");
|
|
} else {
|
|
// SAYING SO IS THE POINT. A tool that exits silently has not told you it
|
|
// found nothing, it has told you nothing at all - and the two look identical
|
|
// from outside. This says what it looked at and what it looked for.
|
|
printf("No style warnings: %d file%s checked against %d rule%s.\n",
|
|
files, files == 1 ? "" : "s",
|
|
RULE_COUNT, RULE_COUNT == 1 ? "" : "s");
|
|
}
|
|
// Said out loud rather than left implicit. A suppression is a claim that something
|
|
// is deliberate, and a count of them is how anybody notices the claim has spread.
|
|
if (suppressionsUsed) {
|
|
printf("%d warning%s suppressed by splitlint comments.\n",
|
|
suppressionsUsed, suppressionsUsed == 1 ? "" : "s");
|
|
}
|
|
}
|
|
return fatalWarnings && warnings ? 1 : 0;
|
|
}
|