Thursday, June 04, 2026 3:47:39 AM
> rpt_parser_controller.js
import ApplicationController from "./application_controller";
import $ from "../helpers/cash_dom";
import * as R from "ramda";

// Connects to data-controller="rpt-parser"
export default class extends ApplicationController {
  static targets = [
    "fileInput",
    "fileName",
    "parseButton",
    "loadingCard",
    "resultsCard",
    "errorCard",
    "errorMessage",
    "errorCount",
    "errorsList",
    "successAlert",
    "removeDuplicates",
    "showExplanations",
  ];

  connect() {
    this.resetState();
    this.initializePatterns();
  }

  // ============================================================================
  // INITIALIZATION & STATE
  // ============================================================================

  resetState() {
    this.errors = [];
    this.rptLines = [];
  }

  initializePatterns() {
    this.patterns = {
      startOfError: /error in expression/i,
      errorPosition: /error position: <(.*)$/i,
      errorReason: /error\s(.*)$/i,

      errorInfos: [
        /file\s*.*,\s*line\s*\d+/i,
        /callextension.*could not be found/i,
      ],

      genericMessages: [
        /callextension .* could not be found$/i,
        /warning message: .* member already defined\.$/i,
        /warning message: .* missing '}'/i,
        /file .*: missing .*/i,
      ],
    };

    this.explanations = [
      {
        regex: /foreign error: unknown enum value: (.*)/gi,
        reason:
          "The enum value '<strong>$1</strong>' is not valid for this command.<br><strong>Quick fix:</strong> Check the <a href='https://community.bistudio.com/wiki' target='_blank'>Bohemia Wiki</a> for valid enum values, or double-check your spelling.",
      },
      {
        regex: /missing ;|missing '}'/gi,
        reason: `<strong>Missing punctuation detected!</strong><br>You're missing a <code>;</code> or <code>}</code> (or have an extra one) around this line.<br><strong>Pro tip:</strong> Arma throws this error for missing/extra: <code>( ) { } [ ]</code><br><strong>What to do:</strong> Check the lines around the error for matching brackets and semicolons.`,
      },
      {
        regex: /invalid number in expression/gi,
        reason:
          "<strong>Expected a number, got something else!</strong><br>A variable or command parameter should be a number but isn't valid.<br><strong>What to check:</strong> Make sure variables contain actual numbers, not strings or nil values.",
      },
      {
        regex: /generic error in expression/gi,
        reason: `<strong>Bohemia's way of saying "¯\\_(ツ)_/¯"</strong><br>This is one of the trickiest errors - the engine knows something's wrong but can't pinpoint what.<br><strong>Debug strategy:</strong> Check syntax around the reported line, look for typos in command names, and verify all brackets match.`,
      },
      {
        regex: /callextension '(.*)' could not be found/i,
        reason:
          "Missing DLL file: <strong>$1</strong><br><strong>What's needed:</strong><br>• <code>$1.dll</code> and/or <code>$1_x64.dll</code> in your server folder<br>• Make sure the DLL name spelling is exactly right<br>• Check that the mod containing this DLL is loaded",
      },
      {
        regex: /undefined variable in expression: (.*)/i,
        reason: `<strong>Variable '<code>$1</code>' doesn't exist!</strong><br><strong>Common causes:</strong><br>• Variable was never initialized (missing <code>$1 = something;</code>)<br>• Variable was set to <code>nil</code><br>• Missing parameter when calling a script<br>• Typo in variable name`,
      },
      {
        regex: /warning message: (.*): \.(.*): member already defined\.$/i,
        reason:
          "Duplicate class definition in <strong>$1</strong><br>The class <code>$2</code> is defined twice in this file.<br><strong>Check for:</strong><br>• Duplicate class definitions<br>• Files included with <code>#include</code> that also define this class",
      },
      {
        regex: /warning message: (.*) missing '}'/i,
        reason:
          "Bracket mismatch in <strong>$1</strong><br><strong>Common issues:</strong><br>• Missing <code>}</code> somewhere before this line<br>• Extra <code>};</code> (semicolon after closing bracket)<br>• Mismatched opening/closing brackets",
      },
      {
        regex: /File (.*), line (.*): '(.*)': missing '(.*)' prior '(.*)'/i,
        reason:
          "Config syntax error in <strong>$1</strong> around line <strong>$2</strong><br>The config entry <code>$3</code> is missing <code>$4</code> before <code>$5</code><br><strong>Quick fix:</strong> Add the missing punctuation where indicated.",
      },
    ];
  }

  // ============================================================================
  // EVENT HANDLERS
  // ============================================================================

  onFileSelected(event) {
    const file = R.head(event.target.files);

    if (file) {
      $(this.fileNameTarget).text(`Selected: ${file.name}`);
      $(this.parseButtonTarget).prop("disabled", false);
    } else {
      $(this.fileNameTarget).text("No file selected");
      $(this.parseButtonTarget).prop("disabled", true);
    }
  }

  async parseFile() {
    const file = R.head(this.fileInputTarget.files);

    if (!file) {
      this.showError("Please select an RPT file to parse");
      return;
    }

    this.showLoading();

    try {
      await this.processFile(file);
      this.showResults();
    } catch (error) {
      console.error("Error processing file:", error);
      this.showError(`Error reading file: ${error.message}`);
    }
  }

  reset() {
    $(this.fileInputTarget).val("");
    $(this.fileNameTarget).text("No file selected");
    $(this.parseButtonTarget).prop("disabled", true);
    $(this.resultsCardTarget).addClass("d-none");
    $(this.errorCardTarget).addClass("d-none");
    this.resetState();
  }

  // ============================================================================
  // FILE PROCESSING
  // ============================================================================

  async processFile(file) {
    const fileContent = await this.readFile(file);
    this.rptLines = fileContent.split(/\r\n|\n/);

    if (R.isEmpty(this.rptLines)) {
      throw new Error("Failed to read RPT file");
    }

    this.parseRPT();

    if ($(this.removeDuplicatesTarget).is(":checked")) {
      this.filterDuplicates();
    }

    if ($(this.showExplanationsTarget).is(":checked")) {
      this.addExplanations();
    }
  }

  parseRPT() {
    let inError = false;
    let currentError = this.createEmptyError();
    let lineCount = 0;

    this.errors = [];

    R.addIndex(R.forEach)((line, index) => {
      lineCount++;

      if (!inError) {
        this.checkGenericMessages(line, index + 1);
      }

      if (!inError && this.patterns.startOfError.test(line)) {
        currentError = this.createEmptyError(index + 1);
        inError = true;
        lineCount = 0;
        return;
      }

      if (inError) {
        if (lineCount >= 30) {
          this.errors.push(currentError);
          inError = false;
          return;
        }

        if (this.processErrorLine(line, currentError)) {
          this.errors.push(currentError);
          inError = false;
        }
      }
    }, this.rptLines);
  }

  checkGenericMessages(line, lineNumber) {
    R.forEach((pattern) => {
      const match = pattern.exec(line);
      if (match) {
        this.errors.push({
          code: "",
          reason: "",
          info: match[0],
          rptLine: lineNumber,
          explanation: "",
        });
      }
    }, this.patterns.genericMessages);
  }

  processErrorLine(line, currentError) {
    const positionMatch = this.patterns.errorPosition.exec(line);
    if (positionMatch) {
      currentError.code = positionMatch[1];
      return false;
    }

    const reasonMatch = this.patterns.errorReason.exec(line);
    if (reasonMatch) {
      currentError.info = reasonMatch[1];
      return false;
    }

    return R.any((regex) => {
      const match = regex.exec(line);
      if (match) {
        currentError.reason = match[0];
        return true;
      }
      return false;
    }, this.patterns.errorInfos);
  }

  // ============================================================================
  // ERROR ENHANCEMENT
  // ============================================================================

  filterDuplicates() {
    const normalizeError = (error) => {
      const parts = [];
      if (error.code?.trim()) parts.push(`code:${error.code.trim()}`);
      if (error.info?.trim()) parts.push(`info:${error.info.trim()}`);
      if (error.reason?.trim()) parts.push(`reason:${error.reason.trim()}`);

      return parts.join("|");
    };

    const seen = new Set();
    this.errors = this.errors.filter((error) => {
      const signature = normalizeError(error);
      if (signature && seen.has(signature)) {
        return false;
      }
      if (signature) seen.add(signature);
      return true;
    });
  }

  addExplanations() {
    this.errors = R.map((error) => {
      const explanation = this.findExplanation(error.info);
      const enhancedError = explanation
        ? R.assoc("explanation", explanation, error)
        : error;

      return this.enhanceErrorContext(enhancedError);
    }, this.errors);
  }

  findExplanation(info) {
    return R.reduce(
      (acc, explanationData) => {
        if (acc) return acc;

        const regex = new RegExp(
          explanationData.regex.source,
          explanationData.regex.flags
        );
        const match = regex.exec(info);

        if (match) {
          return Array.isArray(match)
            ? match[0].replace(regex, explanationData.reason)
            : explanationData.reason;
        }

        return null;
      },
      null,
      this.explanations
    );
  }

  enhanceErrorContext(error) {
    let enhanced = { ...error };

    // Extract file info for header display
    const fileMatch = error.reason?.match(/File (.*?),?\s*line\s*(\d+)/i);
    if (fileMatch) {
      enhanced.fileName = fileMatch[1].replace(/\.\.\.$/, "");
      enhanced.lineNumber = fileMatch[2];
      enhanced.fileReference = `${enhanced.fileName}:${enhanced.lineNumber}`;
    }

    // Only warnings get yellow, everything else is critical
    if (error.info?.toLowerCase().includes("warning message")) {
      enhanced.severity = "warning";
      enhanced.severityLabel = "Warning";
    } else {
      enhanced.severity = "critical";
      enhanced.severityLabel = "Critical";
    }

    return enhanced;
  }

  // ============================================================================
  // UI STATE MANAGEMENT
  // ============================================================================

  showLoading() {
    $(this.loadingCardTarget).removeClass("d-none");
    $(this.resultsCardTarget).addClass("d-none");
    $(this.errorCardTarget).addClass("d-none");
  }

  hideLoading() {
    $(this.loadingCardTarget).addClass("d-none");
  }

  showError(message) {
    $(this.errorMessageTarget).text(message);
    $(this.errorCardTarget).removeClass("d-none");
    $(this.loadingCardTarget).addClass("d-none");
    $(this.resultsCardTarget).addClass("d-none");
  }

  showResults() {
    this.hideLoading();
    $(this.resultsCardTarget).removeClass("d-none");

    const errorCount = this.errors.length;

    if (errorCount === 0) {
      $(this.successAlertTarget).removeClass("d-none");
      $(this.errorCountTarget).text("No errors found! 🎉");
      $(this.errorsListTarget).html("");
    } else {
      $(this.successAlertTarget).addClass("d-none");
      $(this.errorCountTarget).text(
        `${errorCount} error${errorCount !== 1 ? "s" : ""} found`
      );
      this.renderErrors();
    }
  }

  // ============================================================================
  // ERROR RENDERING
  // ============================================================================

  renderErrors() {
    const errorHtml = R.addIndex(R.map)(
      (error, index) => this.createErrorCard(error, index + 1),
      this.errors
    ).join("");

    $(this.errorsListTarget).html(errorHtml);
  }

  createErrorCard(error, index) {
    const severityColor = error.severity === "warning" ? "warning" : "danger";

    return `
      <div class="col-lg-6">
        <div class="card h-100 bg-${severityColor} bg-opacity-10 border-${severityColor}">
          <div class="card-header border-${severityColor}">
            <div class="d-flex align-items-center justify-content-between">
              <h6 class="mb-0 text-${severityColor}">
                <i class="bi bi-exclamation-triangle me-2"></i>
                Error #${index} <small class="text-muted">Line ${
      error.rptLine
    }</small>
              </h6>
              ${
                error.severityLabel
                  ? `<span class="badge bg-${severityColor}">${error.severityLabel}</span>`
                  : ""
              }
            </div>
            ${
              error.fileReference
                ? `
                  <small class="text-muted mt-1 d-block font-monospace">
                    ${error.fileReference}
                    <i class="bi bi-clipboard me-1" style="cursor: pointer;" onclick="navigator.clipboard.writeText('${error.fileReference}')" title="Copy to clipboard"></i>
                  </small>
                `
                : ""
            }
          </div>
          <div class="card-body d-flex flex-column">
            <div class="flex-fill">
              ${this.renderErrorCode(error.code)}
            </div>

            <div class="mt-auto">
              ${this.renderErrorExplanation(error.explanation)}
              ${this.renderErrorInfo(error.info)}
              ${this.renderErrorReason(error.reason)}
            </div>
          </div>
        </div>
      </div>
    `;
  }

  renderErrorCode(code) {
    return code
      ? `
      <div class="mb-3">
        <strong class="text-light">Code:</strong>
        <div class="bg-dark p-2 rounded mt-1">
          <code class="text-warning">${this.escapeHtml(code)}</code>
        </div>
      </div>
    `
      : "";
  }

  renderErrorInfo(info) {
    return info
      ? `
      <div class="mb-2">
        <strong class="text-light">Info:</strong>
        <p class="text-muted mt-1 mb-0 small">${this.escapeHtml(info)}</p>
      </div>
    `
      : "";
  }

  renderErrorReason(reason) {
    if (!reason) return "";

    // Strip redundant file path info since we show it in the header
    const cleanedReason = reason
      .replace(/^File\s+.*?,?\s*line\s*\d+:?\s*/i, "")
      .trim();

    // Skip if empty or too short after cleaning
    if (!cleanedReason || cleanedReason.length < 10) return "";

    return `
      <div class="mb-0">
        <strong class="text-light">Reason:</strong>
        <p class="text-muted mt-1 mb-0 small">${this.escapeHtml(
          cleanedReason
        )}</p>
      </div>
    `;
  }

  renderErrorExplanation(explanation) {
    return explanation
      ? `
      <div class="alert alert-info bg-info bg-opacity-10 border-info mb-3">
        <h6 class="text-info mb-2">
          <i class="bi bi-lightbulb me-2"></i>
          Explanation:
        </h6>
        <div class="small">${explanation}</div>
      </div>
    `
      : "";
  }

  // ============================================================================
  // UTILITIES
  // ============================================================================

  createEmptyError(lineNumber = 0) {
    return {
      code: "",
      reason: "",
      info: "",
      rptLine: lineNumber,
      explanation: "",
    };
  }

  readFile(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = (event) => resolve(event.target.result);
      reader.onerror = () => reject(new Error("Failed to read file"));
      reader.readAsText(file);
    });
  }

  escapeHtml(text) {
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }
}
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 4294fb2