All files / transpiler/logic/analysis IdentifierSyntaxAnalyzer.ts

100% Statements 59/59
100% Branches 18/18
100% Functions 20/20
100% Lines 59/59

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 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253                                                                      1402x 7x   1395x 8x   1387x 18x   1369x                 28x                         33x 7x         26x 8x         18x                             33x               33x                   33x 33x 7x   26x 8x   18x                           359x 359x     67x     67x     6x     6x     26x 26x     49x     49x     92x 92x     22x     22x     60x 60x     10x     10x     160x 160x     380x     380x     105x 105x     422x     422x     3x 3x               359x                 359x   359x 359x   359x                   1402x 1402x   1402x 1369x     33x                            
/**
 * Identifier Syntax Analyzer
 * ADR-063 / Issue #1117: Validates the underscore rule on declared identifiers.
 *
 * A C-Next identifier may not end with `_` and may not contain `__`. That reserves
 * `__` exclusively for the transpiler as the qualified-name separator, which makes
 * `Scope__member` injective: distinct declarations can no longer produce the same C
 * identifier. A leading underscore stays legal — injectivity constrains only the
 * separator's left boundary, so the `_handler` private-member idiom (ADR-029) is
 * unaffected.
 *
 * Only DECLARATION contexts are checked. References are deliberately skipped, which
 * is what lets a C-Next file call an external C symbol such as `__disable_irq()`
 * without tripping the rule (ADR-010, ADR-063).
 */
 
import { ParseTreeWalker, TerminalNode } from "antlr4ng";
import { CNextListener } from "../parser/grammar/CNextListener";
import * as Parser from "../parser/grammar/CNextParser";
import IIdentifierSyntaxError from "./types/IIdentifierSyntaxError";
import TIdentifierViolation from "./types/TIdentifierViolation";
import ReservedCnxName from "../../../utils/ReservedCnxName";
 
/**
 * Pure function classifying an identifier against the ADR-063 rule.
 *
 * @param identifierName - The identifier as written in the source
 * @returns The violation kind, or null when the identifier is legal
 */
function classifyIdentifier(
  identifierName: string,
): TIdentifierViolation | null {
  // Checked first because it is the more specific diagnosis: `cnx__value` breaks
  // both rules, and naming the reserved prefix tells the author more than
  // pointing at the underscores.
  if (ReservedCnxName.isReserved(identifierName)) {
    return "reserved-prefix";
  }
  if (identifierName.includes("__")) {
    return "consecutive";
  }
  if (identifierName.endsWith("_")) {
    return "trailing";
  }
  return null;
}
 
/**
 * The diagnostic code for each violation kind.
 *
 * Part 1 of ADR-063 is E0201; part 2 is E0202. Keeping the mapping beside the
 * classifier means a new rule cannot be added without deciding its code.
 */
const VIOLATION_CODES: Readonly<Record<TIdentifierViolation, string>> = {
  trailing: "E0201",
  consecutive: "E0201",
  "reserved-prefix": "E0202",
};
 
/**
 * Pure function creating the error message for a violation.
 */
function formatIdentifierSyntaxError(
  identifierName: string,
  violation: TIdentifierViolation,
): string {
  if (violation === "reserved-prefix") {
    return (
      `Identifier '${identifierName}' cannot begin with '${ReservedCnxName.PREFIX}'. ` +
      `That prefix is reserved for names the transpiler generates, compared case-insensitively.`
    );
  }
  if (violation === "consecutive") {
    return (
      `Identifier '${identifierName}' cannot contain consecutive underscores. ` +
      `'__' is reserved as the separator for scope-qualified names in generated C.`
    );
  }
  return (
    `Identifier '${identifierName}' cannot end with an underscore. ` +
    `A trailing underscore would make scope-qualified names ambiguous in generated C.`
  );
}
 
/**
 * Pure function producing a name that satisfies the rule.
 *
 * Applies BOTH normalizations regardless of which clause was reported, because an
 * identifier can break both at once. `classifyIdentifier` reports "consecutive"
 * first, so fixing only that would suggest `my_value_` for `my__value_` — a name
 * E0201 itself rejects.
 */
function suggestLegalIdentifier(identifierName: string): string {
  const withoutReservedPrefix = ReservedCnxName.isReserved(identifierName)
    ? identifierName.slice(ReservedCnxName.PREFIX.length)
    : identifierName;
 
  // The trailing match is a single `_`, not `_+`: the collapse above has already
  // reduced every run to one underscore, so at most one can remain at the end.
  // `_+$` would be equivalent but backtracks super-linearly on a long run of
  // underscores (SonarCloud S8786).
  return withoutReservedPrefix.replaceAll(/_+/g, "_").replace(/_$/, "");
}
 
/**
 * Pure function creating the help text for a violation.
 */
function formatIdentifierSyntaxHelp(
  identifierName: string,
  violation: TIdentifierViolation,
): string {
  const suggestion = suggestLegalIdentifier(identifierName);
  if (violation === "reserved-prefix") {
    return `Drop the reserved prefix (e.g. '${suggestion}')`;
  }
  if (violation === "consecutive") {
    return `Use a single underscore instead (e.g. '${suggestion}')`;
  }
  return `Remove the trailing underscore (e.g. '${suggestion}')`;
}
 
/**
 * Listener that walks every declaration context checking the declared identifier.
 *
 * Each handler delegates to the analyzer's single check() entry point rather than
 * repeating the classify/report logic, which keeps the thirteen near-identical
 * handlers from tripping duplication analysis.
 */
class IdentifierSyntaxListener extends CNextListener {
  private readonly analyzer: IdentifierSyntaxAnalyzer;
 
  constructor(analyzer: IdentifierSyntaxAnalyzer) {
    super();
    this.analyzer = analyzer;
  }
 
  override enterScopeDeclaration = (
    ctx: Parser.ScopeDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterRegisterDeclaration = (
    ctx: Parser.RegisterDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterRegisterMember = (ctx: Parser.RegisterMemberContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterStructDeclaration = (
    ctx: Parser.StructDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterStructMember = (ctx: Parser.StructMemberContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterEnumDeclaration = (
    ctx: Parser.EnumDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterEnumMember = (ctx: Parser.EnumMemberContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterBitmapDeclaration = (
    ctx: Parser.BitmapDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterBitmapMember = (ctx: Parser.BitmapMemberContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterFunctionDeclaration = (
    ctx: Parser.FunctionDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterParameter = (ctx: Parser.ParameterContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterVariableDeclaration = (
    ctx: Parser.VariableDeclarationContext,
  ): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
 
  override enterForVarDecl = (ctx: Parser.ForVarDeclContext): void => {
    this.analyzer.check(ctx.IDENTIFIER());
  };
}
 
/**
 * Analyzer for identifier syntax violations (ADR-063)
 */
class IdentifierSyntaxAnalyzer {
  private errors: IIdentifierSyntaxError[] = [];
 
  /**
   * Analyze the parse tree for identifier syntax violations
   *
   * @param tree - The parsed program AST
   * @returns Array of errors (empty if all pass)
   */
  public analyze(tree: Parser.ProgramContext): IIdentifierSyntaxError[] {
    this.errors = [];
 
    const listener = new IdentifierSyntaxListener(this);
    ParseTreeWalker.DEFAULT.walk(listener, tree);
 
    return this.errors;
  }
 
  /**
   * Check one declared identifier, recording an error if it breaks the rule.
   *
   * Single entry point for all declaration contexts, so the rule and its reported
   * position are defined in exactly one place.
   */
  public check(identifier: TerminalNode): void {
    const identifierName = identifier.getText();
    const violation = classifyIdentifier(identifierName);
 
    if (violation === null) {
      return;
    }
 
    this.errors.push({
      code: VIOLATION_CODES[violation],
      identifierName,
      violation,
      // The identifier's own token, not the enclosing rule's start token
      line: identifier.symbol.line,
      column: identifier.symbol.column,
      message: formatIdentifierSyntaxError(identifierName, violation),
      helpText: formatIdentifierSyntaxHelp(identifierName, violation),
    });
  }
}
 
export default IdentifierSyntaxAnalyzer;