001package gudusoft.gsqlparser.pp.score; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.TCustomSqlStatement; 005import gudusoft.gsqlparser.TGSqlParser; 006import gudusoft.gsqlparser.pp2.token.TokenEquivalence; 007 008import java.util.ArrayList; 009import java.util.Arrays; 010import java.util.LinkedHashMap; 011import java.util.List; 012import java.util.Locale; 013import java.util.Map; 014 015/** 016 * PrettyScore: scores the readability of an <b>already formatted</b> SQL text 017 * against a style {@link ScoreProfile} and explains every deduction. 018 * 019 * <pre> 020 * ScoreReport r = new PrettyScore().score(formattedSql, EDbVendor.dbvmssql, ScoreProfile.defaults(), originalSql, null); 021 * r.getScore(); // 0-100 022 * r.getAttainable(); // what the formatter could reach on this input 023 * r.getRatio(); // score / attainable — the closed-loop target 024 * r.toJson(); // published contract, version "prettyscore/0.1" 025 * </pre> 026 * 027 * <p><b>Read this before quoting a number.</b> While the profile's 028 * calibration is {@code heuristic-v0} the weights are engineering guesses: 029 * the score is reliable only as a relative measure within one profile on one 030 * corpus (before/after a formatter change). It is not an absolute readability 031 * verdict and must not be presented as one. A script that never wraps (one 032 * over-wide line holding most tokens) is charged on width, density and indent 033 * even when local agreement would otherwise look perfect. 034 * 035 * <p>Syntax errors are fine: the lexical facts come from the pp2 pipeline, 036 * which never needs a successful parse; only the AST depth is lost, in which 037 * case the indent dimension's weight is halved and a note is attached. 038 * 039 * <p>Thread-safety: the only configuration is {@link #setAstEnabled}, read 040 * once at the start of every {@code score} call; instances may be shared. 041 */ 042public final class PrettyScore { 043 044 private volatile boolean astEnabled = true; 045 046 /** 047 * Whether a second GSP parse is run to stamp AST depth (default true). 048 * Disabling it makes scoring O(tokens) with no parser cost, at the price 049 * of the indent dimension's weight being halved as if the text had not 050 * parsed. Useful for very large scripts or pure lexical regression runs. 051 */ 052 public PrettyScore setAstEnabled(boolean enabled) { 053 this.astEnabled = enabled; 054 return this; 055 } 056 057 public boolean isAstEnabled() { 058 return astEnabled; 059 } 060 061 /** Score {@code sql} with the default profile and no fidelity gate. */ 062 public ScoreReport score(String sql, EDbVendor vendor) { 063 return score(sql, vendor, ScoreProfile.defaults(), null, null); 064 } 065 066 /** 067 * @param sql the formatted SQL; must not be null 068 * @param vendor dialect; must not be null 069 * @param profile style profile; null selects {@code isf-default} 070 * @param original the un-formatted input; when given the fidelity gate 071 * (token / comment / AST preservation) and the comments 072 * dimension are evaluated 073 * @param options the formatter options the caller claims produced 074 * {@code sql} ({@code GFmtOpt} field names); when given, 075 * contradictions surface as {@code OPTION_NOT_HONORED} 076 */ 077 public ScoreReport score(String sql, EDbVendor vendor, ScoreProfile profile, String original, 078 Map<String, Object> options) { 079 if (sql == null) throw new NullPointerException("sql"); 080 if (vendor == null) throw new NullPointerException("vendor"); 081 if (profile == null) profile = ScoreProfile.defaults(); 082 final boolean ast = astEnabled; // snapshot: a concurrent setter cannot split one scoring run 083 084 FactsBuilder builder = new FactsBuilder(profile); 085 Facts facts = builder.build(sql, vendor, ast); 086 Facts origFacts = original == null ? null : builder.build(original, vendor, false); 087 Geometry geo = new Geometry(facts, profile); 088 RuleContext ctx = new RuleContext(facts, profile, geo, origFacts, options); 089 090 List<Rule> rules = Arrays.<Rule>asList(new SpineRule(), new IndentRule(), new WidthRule(), new DensityRule(), 091 new AlignmentRule(), new ConsistencyRule(), new GroupingRule(), new CommentRule(), new OptionRule()); 092 for (Rule r : rules) { 093 r.apply(ctx); 094 } 095 Explainer.depthMetrics(facts, ctx.metrics); 096 097 // ---- gate 098 List<ScoreReport.GateCheck> checks = new ArrayList<ScoreReport.GateCheck>(); 099 boolean gateEvaluated = original != null; 100 boolean gatePassed = true; 101 if (gateEvaluated) { 102 boolean tokens = TokenEquivalence.equalsModuloFormatting(original, sql, null, vendor, true); 103 checks.add(new ScoreReport.GateCheck("TOKEN_PRESERVED", tokens, 104 tokens ? "" : "solid token sequence differs from the original", null)); 105 Map<String, Object> extra = new LinkedHashMap<String, Object>(); 106 extra.put("input", origFacts.getCommentCount()); 107 extra.put("output", facts.getCommentCount()); 108 boolean comments = origFacts.getCommentCount() == facts.getCommentCount(); 109 checks.add(new ScoreReport.GateCheck("COMMENTS_PRESERVED", comments, 110 comments ? "" : "comment count changed", extra)); 111 if (tokens) { 112 int recased = recasedIdentifiers(origFacts, facts); 113 Map<String, Object> idExtra = new LinkedHashMap<String, Object>(); 114 idExtra.put("changed", recased); 115 boolean idOk = recased == 0 || !profile.isStrictIdentifierCase(); 116 checks.add(new ScoreReport.GateCheck("IDENTIFIER_CASE", idOk, 117 recased == 0 ? "" : recased + " unquoted identifier(s) recased" + (idOk ? " (informational; profile.strictIdentifierCase=false)" : ""), idExtra)); 118 } 119 if (ast) { 120 String astDetail = astEquivalent(original, sql, vendor); 121 if (astDetail != null) { 122 checks.add(new ScoreReport.GateCheck("AST_EQUIVALENT", astDetail.isEmpty(), astDetail, null)); 123 } 124 } else { 125 checks.add(new ScoreReport.GateCheck("AST_EQUIVALENT", true, "not evaluated (ast disabled)", null)); 126 } 127 for (ScoreReport.GateCheck c : checks) if (!c.isOk()) gatePassed = false; 128 } 129 Map<String, Object> parseExtra = new LinkedHashMap<String, Object>(); 130 parseExtra.put("parse_ok", facts.isParseOk()); 131 parseExtra.put("error_regions", facts.getErrorRegions()); 132 parseExtra.put("unclosed_blocks", facts.getUnclosedBlocks()); 133 checks.add(new ScoreReport.GateCheck("PARSE_STATUS", true, 134 facts.isParseOk() ? "parsed" : "not parsed; pp2-tolerant facts only", parseExtra)); 135 136 Explainer.Result agg = Explainer.aggregate(ctx.findings, profile, facts, gatePassed, ctx.metrics); 137 138 ctx.metrics.put("statements", facts.getStatementCount()); 139 ctx.metrics.put("comments", facts.getCommentCount()); 140 if (!ast) ctx.notes.add("ast disabled by caller: parse status not evaluated, indent weight halved"); 141 ScoreReport.ParseInfo parse = new ScoreReport.ParseInfo(ast, facts.isParseOk(), 142 !ast ? "pp2-lexical (ast disabled)" : facts.isParseOk() ? "gsp-parse+pp2" : "pp2-tolerant", 143 facts.getErrorRegions(), facts.isParseOk() ? facts.getParserStatementCount() : facts.getStatementCount(), 144 facts.isAstAvailable()); 145 return new ScoreReport(profile.getName(), profile.getCalibration(), vendor.name(), parse, 146 gateEvaluated, gatePassed, checks, agg.score, agg.attainable, agg.unfixableImpact, 147 agg.dimensions, agg.findings, agg.improvements, ctx.metrics, ctx.notes); 148 } 149 150 /** The formatter option / renderer component a rule id maps to (see the improvement list). */ 151 public static String targetFor(String rule) { 152 return OptionAdvisor.target(rule); 153 } 154 155 /** 156 * Number of unquoted identifier tokens whose text differs only by case 157 * between the two token sequences (which the TOKEN_PRESERVED check has 158 * already found equal modulo case). 159 */ 160 static int recasedIdentifiers(Facts a, Facts b) { 161 int n = 0; 162 int i = 0, j = 0; 163 while (i < a.tokenCount() && j < b.tokenCount()) { 164 TokenFacts x = a.token(i); 165 TokenFacts y = b.token(j); 166 if (x.isComment()) { i++; continue; } 167 if (y.isComment()) { j++; continue; } 168 boolean identifierLike = x.getType() == gudusoft.gsqlparser.ETokenType.ttidentifier 169 || x.getType() == gudusoft.gsqlparser.ETokenType.ttnonreservedkeyword 170 || (x.getType() == gudusoft.gsqlparser.ETokenType.ttkeyword && !RuleSupport.isStructuralKeyword(a, x)); 171 if (identifierLike && !x.getText().equals(y.getText()) && x.getUpper().equals(y.getUpper())) { 172 n++; 173 } 174 i++; 175 j++; 176 } 177 return n; 178 } 179 180 /** 181 * Compare the two parses structurally: statement count and statement types. 182 * Returns null when either side does not parse (check not applicable), 183 * "" when equivalent, otherwise a description of the difference. 184 */ 185 static String astEquivalent(String original, String formatted, EDbVendor vendor) { 186 TGSqlParser a = new TGSqlParser(vendor); 187 a.sqltext = original; 188 TGSqlParser b = new TGSqlParser(vendor); 189 b.sqltext = formatted; 190 int ra, rb; 191 try { 192 ra = a.parse(); 193 rb = b.parse(); 194 } catch (Throwable t) { 195 return null; 196 } 197 if (ra != 0 && rb != 0) return null; 198 if (ra != 0 || rb != 0) { 199 return String.format(Locale.ROOT, "original %s, formatted %s", ra == 0 ? "parses" : "fails to parse", rb == 0 ? "parses" : "fails to parse"); 200 } 201 int na = a.sqlstatements.size(), nb = b.sqlstatements.size(); 202 if (na != nb) return "statement count " + na + " vs " + nb; 203 for (int i = 0; i < na; i++) { 204 TCustomSqlStatement sa = a.sqlstatements.get(i); 205 TCustomSqlStatement sb = b.sqlstatements.get(i); 206 if (sa.sqlstatementtype != sb.sqlstatementtype) { 207 return "statement " + (i + 1) + " type " + sa.sqlstatementtype + " vs " + sb.sqlstatementtype; 208 } 209 int ta = sa.tables == null ? 0 : sa.tables.size(); 210 int tb = sb.tables == null ? 0 : sb.tables.size(); 211 if (ta != tb) return "statement " + (i + 1) + " table count " + ta + " vs " + tb; 212 } 213 return ""; 214 } 215}