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