All files / shared/parsers hardware-compatibility-parser.ts

97.4% Statements 75/77
80.55% Branches 29/36
100% Functions 31/31
98.55% Lines 68/69

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209                    17x       17x     25x             6x             131x 128x 181x 6x       131x 128x 181x 36x           131x 5x         6x 6x                           4x 4x                         131x     131x                 131x 19x 6x                       131x 131x 181x 128x   14x 7x 7x   128x     128x   128x 7x                                         131x       131x 131x   128x 89x   131x   5x 2x   131x 131x   131x 62x 24x 6x 6x 6x 18x 3x 3x       131x 3x 2x   1x 1x     131x       131x 131x 128x 148x   181x 128x   2x 2x                   131x      
import type { ParserMessage } from "../schema";
import { randomUUID } from "node:crypto";
import {
  type StaticIOAnalysis,
  type StaticIOCall,
  type StaticIOOperation,
  type UnresolvedStaticIOCall,
} from "@shared/io-registry-parser";
import { UNO_PWM_PINS, isUnoPwmPin } from "@shared/utils/arduino-utils";
 
const DIGITAL_OPERATIONS = new Set<StaticIOOperation>([
  "digitalRead",
  "digitalWrite",
]);
const LITERAL_PIN_EXPRESSION = /^(?:\d+|A\d+)$/;
 
function sourceLocation(call: StaticIOCall | UnresolvedStaticIOCall): Pick<ParserMessage, "file" | "line"> {
  return {
    line: call.line,
    ...(call.file ? { file: call.file } : {}),
  };
}
 
function pinLabel(pinId: number): string {
  return pinId >= 14 ? `A${pinId - 14}` : String(pinId);
}
 
function resolvedCalls(
  analysis: StaticIOAnalysis,
  operation: StaticIOOperation,
): StaticIOCall[] {
  return analysis.pins
    .flatMap(({ calls }) => calls)
    .filter(({ op }) => op === operation)
    .sort((a, b) => a.line - b.line);
}
 
function digitalCalls(analysis: StaticIOAnalysis): StaticIOCall[] {
  return analysis.pins
    .flatMap(({ calls }) => calls)
    .filter(({ op }) => DIGITAL_OPERATIONS.has(op))
    .sort((a, b) => a.line - b.line);
}
 
function unresolvedDigitalCalls(
  analysis: StaticIOAnalysis,
): UnresolvedStaticIOCall[] {
  return analysis.unresolvedCalls.filter(({ op }) =>
    DIGITAL_OPERATIONS.has(op),
  );
}
 
function createLiteralMissingPinModeMessage(call: StaticIOCall): ParserMessage {
  const pin = call.sourceExpression;
  return {
    id: randomUUID(),
    type: "warning",
    category: "hardware",
    severity: 2,
    message: `Pin ${pin} used with digitalRead/digitalWrite but pinMode() was not called for this pin.`,
    suggestion: `pinMode(${pin}, INPUT);`,
    ...sourceLocation(call),
  };
}
 
function createVariableMissingPinModeMessage(
  call: StaticIOCall | UnresolvedStaticIOCall,
): ParserMessage {
  const variable = call.sourceExpression;
  return {
    id: randomUUID(),
    type: "warning",
    category: "hardware",
    severity: 2,
    message: `Variable '${variable}' used in digitalRead/digitalWrite but no pinMode() call found for this variable.`,
    suggestion: `pinMode(${variable}, INPUT);`,
    ...sourceLocation(call),
  };
}
 
/** Derives hardware compatibility messages from canonical static I/O facts. */
export class HardwareCompatibilityParser {
  constructor(private readonly analysis: StaticIOAnalysis) {}
 
  parse(): ParserMessage[] {
    return [
      ...this.checkAnalogWritePWM(),
      ...this.checkPinModeConflicts(),
      ...this.checkMissingPinModes(),
      ...this.checkOutputPinsReadAsInput(),
    ];
  }
 
  private checkAnalogWritePWM(): ParserMessage[] {
    return resolvedCalls(this.analysis, "analogWrite")
      .filter(({ pinId }) => !isUnoPwmPin(pinId))
      .map((call) => ({
        id: randomUUID(),
        type: "warning" as const,
        category: "hardware" as const,
        severity: 2 as const,
        message: `analogWrite(${call.sourceExpression}, ...) used on pin ${call.pinId}, which doesn't support PWM on Arduino UNO. PWM pins: ${UNO_PWM_PINS.join(", ")}.`,
        suggestion: "// Use PWM pin instead: analogWrite(3, value);",
        ...sourceLocation(call),
      }));
  }
 
  private checkPinModeConflicts(): ParserMessage[] {
    const messages: ParserMessage[] = [];
    for (const { pinId, calls } of this.analysis.pins) {
      const pinModeCalls = calls.filter(({ op }) => op === "pinMode");
      if (pinModeCalls.length < 2) continue;
 
      const modes = pinModeCalls.flatMap(({ mode }) => mode ?? []);
      const uniqueModes = [...new Set(modes)];
      const firstExpression = pinModeCalls[0]?.sourceExpression;
      const pin =
        firstExpression && LITERAL_PIN_EXPRESSION.test(firstExpression)
          ? firstExpression
          : pinLabel(pinId);
      const conflictCall = pinModeCalls[1];
 
      if (uniqueModes.length > 1) {
        messages.push({
          id: randomUUID(),
          type: "warning",
          category: "pins",
          severity: 2,
          message: `Pin ${pin} has multiple pinMode() calls with different modes: ${uniqueModes.join(", ")}.`,
          suggestion: `Use a single pinMode(${pin}, <MODE>) call in setup().`,
          ...(conflictCall ? sourceLocation(conflictCall) : {}),
        });
      } else E{
        messages.push({
          id: randomUUID(),
          type: "warning",
          category: "pins",
          severity: 2,
          message: `Pin ${pin} has pinMode() called multiple times (${pinModeCalls.length}x).`,
          suggestion: `Remove duplicate pinMode(${pin}, ${uniqueModes[0]}) calls.`,
          ...(conflictCall ? sourceLocation(conflictCall) : {}),
        });
      }
    }
    return messages;
  }
 
  private checkMissingPinModes(): ParserMessage[] {
    const messages: ParserMessage[] = [];
    const configuredPins = new Set(
      this.analysis.pins
        .filter(({ calls }) => calls.some(({ op }) => op === "pinMode"))
        .map(({ pinId }) => pinId),
    );
    const configuredExpressions = new Set(
      this.analysis.unresolvedCalls
        .filter(({ op }) => op === "pinMode")
        .map(({ sourceExpression }) => sourceExpression),
    );
    const warnedLiterals = new Set<string>();
    let warnedVariable = false;
 
    for (const call of digitalCalls(this.analysis)) {
      if (configuredPins.has(call.pinId)) continue;
      if (LITERAL_PIN_EXPRESSION.test(call.sourceExpression)) {
        Iif (warnedLiterals.has(call.sourceExpression)) continue;
        warnedLiterals.add(call.sourceExpression);
        messages.push(createLiteralMissingPinModeMessage(call));
      } else if (!warnedVariable) {
        messages.push(createVariableMissingPinModeMessage(call));
        warnedVariable = true;
      }
    }
 
    for (const call of unresolvedDigitalCalls(this.analysis)) {
      if (warnedVariable || configuredExpressions.has(call.sourceExpression)) {
        continue;
      }
      messages.push(createVariableMissingPinModeMessage(call));
      warnedVariable = true;
    }
 
    return messages;
  }
 
  private checkOutputPinsReadAsInput(): ParserMessage[] {
    const messages: ParserMessage[] = [];
    for (const { pinId, calls } of this.analysis.pins) {
      const isOutput = calls.some(
        ({ op, mode }) => op === "pinMode" && mode === "OUTPUT",
      );
      const readCall = calls.find(({ op }) => op === "digitalRead");
      if (!isOutput || !readCall) continue;
 
      const pin = pinLabel(pinId);
      messages.push({
        id: randomUUID(),
        type: "warning",
        category: "pins",
        severity: 2,
        message: `Pin ${pin} is configured as OUTPUT but read with digitalRead(). Reading an OUTPUT pin may return unexpected values.`,
        suggestion: `If you need to read the pin, use pinMode(${pin}, INPUT) or INPUT_PULLUP instead.`,
        ...sourceLocation(readCall),
      });
    }
    return messages;
  }
}