001package gudusoft.gsqlparser.dlineage.comment;
002
003import gudusoft.gsqlparser.*;
004import gudusoft.gsqlparser.nodes.*;
005import gudusoft.gsqlparser.dlineage.dataflow.model.*;
006import gudusoft.gsqlparser.dlineage.util.Pair;
007import gudusoft.gsqlparser.dlineage.comment.CommentBindingBundle.*;
008import gudusoft.gsqlparser.dlineage.comment.CommentHostCollector.Draft;
009import java.nio.charset.StandardCharsets;
010import java.security.MessageDigest;
011import java.util.*;
012
013/** Per-analysis, opt-in draft store. build() releases all AST and token references. */
014public final class CommentBindingCollector {
015    private static final java.util.regex.Pattern ENGINE_DIRECTIVE = java.util.regex.Pattern.compile("(?m)(?:^|,)\\s*(?:--\\s*)?(?:db-instance|db|schema|process_label|process)\\s*[:=]");
016    private static final List<String> KINDS = Arrays.asList("container", "routine", "call", "statement", "column", "table_ref", "object_declaration");
017    private static final class Unit {
018        SourceUnit source; String sql; int base; CommentTokenIndex index; boolean derived;
019        final List<Draft> hosts = new ArrayList<Draft>();
020        final List<TSourceToken> comments = new ArrayList<TSourceToken>();
021    }
022    private static final class Parsed {
023        CommentTokenIndex index; CommentHostCollector hosts; List<Unit> units; boolean partial;
024        TSourceTokenList tokens;
025    }
026    private final Option option;
027    private final Map<String,List<Unit>> inputs = new LinkedHashMap<String,List<Unit>>();
028    private final List<Parsed> parsed = new ArrayList<Parsed>();
029    private final List<Unit> units = new ArrayList<Unit>();
030    private boolean failed;
031    private boolean missingSource;
032    private int derivedOrdinal;
033    private final Set<TSourceTokenList> captured = Collections.newSetFromMap(new IdentityHashMap<TSourceTokenList, Boolean>());
034    public CommentBindingCollector(Option option) { this.option = option; }
035
036    public void register(String key, String sql, int base, int inputOrdinal, String fileName, boolean derived) {
037        try { registerUnit(key, sql, base, inputOrdinal, fileName, derived); }
038        catch (Exception e) { failed = true; }
039    }
040    private Unit registerUnit(String key, String sql, int base, int ordinal, String fileName, boolean derived) throws Exception {
041        Unit u = new Unit(); u.sql = sql; u.base = base; u.derived = derived;
042        String id = "s" + (units.size() + 1);
043        byte[] hash = MessageDigest.getInstance("SHA-256").digest(sql.getBytes(StandardCharsets.UTF_8));
044        StringBuilder digest = new StringBuilder(); for (byte b : hash) digest.append(String.format(Locale.ROOT, "%02x", b & 255));
045        if (fileName != null) fileName = fileName.replace('\\', '/');
046        if (fileName != null) fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
047        u.source = new SourceUnit(id, ordinal, option.getVendor().toString(), digest.toString(), derived ? "derived" : "original", fileName);
048        u.index = new CommentTokenIndex(sql, new TSourceTokenList());
049        units.add(u);
050        inputs.computeIfAbsent(key, k -> new ArrayList<Unit>()).add(u);
051        return u;
052    }
053    /** Imported lineage without a bundle does not provide original SQL comment facts. */
054    public void sourceUnavailable() { missingSource = true; }
055
056    public void captureDerived(TGSqlParser parser, boolean parsePartial) {
057        capture("derived:" + (++derivedOrdinal), parser, parsePartial);
058    }
059    public void capture(String key, TGSqlParser parser, boolean parsePartial) {
060        try {
061            Parsed primary = captureOne(key, parser, parsePartial);
062            Deque<TGSqlParser> foreign = new ArrayDeque<TGSqlParser>(primary.hosts.foreignParsers);
063            while (!foreign.isEmpty()) {
064                TGSqlParser nested = foreign.removeFirst();
065                if (captured.contains(nested.sourcetokenlist)) continue;
066                // Routine-body parsers already ran. Capture iteratively without parsing again.
067                Parsed child = captureOne("derived:" + (++derivedOrdinal), nested, nested.getErrorCount() != 0);
068                foreign.addAll(child.hosts.foreignParsers);
069            }
070        } catch (Exception e) { failed = true; }
071    }
072    private Parsed captureOne(String key, TGSqlParser parser, boolean parsePartial) throws Exception {
073        captured.add(parser.sourcetokenlist);
074        Parsed p = new Parsed();
075        p.units = inputs.get(key);
076        if (p.units == null) {
077            Unit u = registerUnit(key, parser.sqltext, 0, units.size(), null, true);
078            p.units = Collections.singletonList(u);
079        }
080        p.tokens = parser.sourcetokenlist;
081        p.index = new CommentTokenIndex(parser.sqltext, parser.sourcetokenlist);
082        p.hosts = new CommentHostCollector(p.index, parser);
083        p.partial = parsePartial;
084        parsed.add(p);
085        return p;
086    }
087    public void clear() { inputs.clear(); parsed.clear(); units.clear(); captured.clear(); }
088
089    public CommentBindingBundle build(ModelBindingManager manager) {
090        CommentBindingBundle out = new CommentBindingBundle();
091        out.supportedHostKinds.addAll(KINDS);
092        out.limits = new Limits(option.getCommentBindingMaxRawLength(), option.getCommentBindingMaxCommentsPerSourceUnit(), option.getCommentBindingMaxTotalRawLength());
093        try {
094            final IdentityHashMap<Object,List<Target>> models = new IdentityHashMap<Object,List<Target>>();
095            manager.visitExactBindings((key, model) -> observe(models, key, model));
096            IdentityHashMap<TSourceToken,Parsed> tokenOwners = new IdentityHashMap<TSourceToken,Parsed>();
097            for (Parsed p : parsed) for (TSourceToken token : p.index.code) tokenOwners.put(token,p);
098            for (Relationship relation : manager.getRelations()) if (relation instanceof CallRelationship) {
099                CallRelationship call = (CallRelationship)relation;
100                addTarget(models, call.getCallObject(), new Target("relationship", String.valueOf(call.getId()), null));
101                Parsed p = call.getCallObject() == null || call.getCallObject().getStartToken() == null ? null
102                        : tokenOwners.get(call.getCallObject().getStartToken());
103                if (p != null) {
104                    Draft h = p.hosts.add(call.getCallObject(), null, "call", "call_occurrence", null, 0);
105                    if (h != null) h.kind = "call";
106                }
107            }
108            int total = 0;
109            Set<Unit> processed = Collections.newSetFromMap(new IdentityHashMap<Unit, Boolean>());
110            for (Unit u : units) out.sourceUnits.add(u.source);
111            for (Parsed p : parsed) {
112                p.hosts.hosts.sort(Comparator.comparingInt((Draft h) -> h.start).thenComparingInt(h -> -h.end).thenComparing(h -> h.kind));
113                for (int i = 0; i < p.hosts.hosts.size(); i++) p.hosts.hosts.get(i).id = "h" + (out.hosts.size() + i + 1);
114                NavigableMap<Integer,Unit> boundaries = new TreeMap<Integer,Unit>();
115                for (Unit u : p.units) boundaries.put(u.base,u);
116                for (Draft h : p.hosts.hosts) {
117                    Map.Entry<Integer,Unit> entry = boundaries.floorEntry(h.start);
118                    if (entry != null && h.end <= entry.getValue().base + entry.getValue().sql.length()) entry.getValue().hosts.add(h);
119                }
120                for (TSourceToken token : p.index.comments) {
121                    Map.Entry<Integer,Unit> entry = boundaries.floorEntry(CommentTokenIndex.start(token));
122                    if (entry != null && CommentTokenIndex.end(token) <= entry.getValue().base + entry.getValue().sql.length()) entry.getValue().comments.add(token);
123                }
124                CommentBinder binder = new CommentBinder(p.index, p.hosts.hosts, p.hosts.expressionBoundaries);
125                for (Unit u : p.units) {
126                    processed.add(u);
127                    String sourceId = u.source.id;
128                    Draft container = new Draft(); container.id = sourceId;
129                    container.kind = "container"; container.start = u.base; container.end = u.base + u.sql.length();
130                    out.hosts.add(new Host(container.id, sourceId, "container", "script", range(u, u.base, container.end), null, null, null, 0,
131                            "source_only", Collections.<Target>emptyList(), null, null));
132                    for (Draft h : u.hosts) {
133                        List<Target> targets = new ArrayList<Target>();
134                        copyTargets(targets, models.get(h.node));
135                        if (!"select_item".equals(h.syntax)) copyTargets(targets, models.get(h.anchor));
136                        if (h.node instanceof TResultColumn && ((TResultColumn)h.node).getExpr() != null
137                                && h.syntax.contains("assignment")) {
138                            TExpression lhs = ((TResultColumn)h.node).getExpr().getLeftOperand();
139                            copyTargets(targets, models.get(lhs));
140                            if (lhs != null) copyTargets(targets, models.get(lhs.getObjectOperand()));
141                        }
142                        if ("call".equals(h.kind)) targets.removeIf(t -> !"relationship".equals(t.space));
143                        String processId = null, procedureId = null;
144                        Draft parent = h.parent;
145                        if (parent != null && (parent.start < u.base || parent.end > container.end)) parent = null;
146                        Draft query = parent;
147                        while (query != null && !"sstselect".equals(query.syntax)) query = query.parent;
148                        for (Target t : targets) {
149                            if ("process".equals(t.space)) processId = t.id;
150                            if ("procedure".equals(t.space)) procedureId = t.id;
151                        }
152                        out.hosts.add(new Host(h.id, sourceId, h.kind, h.syntax, range(u,h.start,h.end), range(u,h.nameStart,h.nameEnd),
153                                query == null ? null : query.id, parent == null ? container.id : parent.id, h.ordinal,
154                                targets.isEmpty() ? "source_only" : "resolved", targets, processId, procedureId));
155                    }
156                    boolean limited = false; int count = 0;
157                    for (TSourceToken token : u.comments) {
158                        int a = CommentTokenIndex.start(token), b = CommentTokenIndex.end(token);
159                        if (a < u.base || b > container.end) continue;
160                        if (count >= out.limits.maxCommentsPerSourceUnit) { limited = true; break; }
161                        count++;
162                        String id = sourceId + ":c" + count;
163                        if (!p.index.exact(token)) { diagnostic(out,"SOURCE_MAP_UNAVAILABLE",sourceId,id,null); continue; }
164                        String raw = token.getAstext(); String lexical = lexicalKind(raw);
165                        int length = raw.length(), keep = Math.min(length, Math.min(out.limits.maxRawLength, Math.max(0,out.limits.maxTotalRawLength-total)));
166                        if (keep < length && keep > 0 && Character.isHighSurrogate(raw.charAt(keep-1))) keep--;
167                        total += keep; String encoding = null;
168                        if (invalidSurrogate(raw)) { diagnostic(out,"INVALID_INPUT",sourceId,id,null); raw = ""; keep = 0; }
169                        else raw = raw.substring(0,keep);
170                        boolean truncated = keep < length;
171                        if (truncated) limited = true;
172                        if (!xmlSafe(raw)) { raw = Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); encoding = "base64-utf8"; }
173                        out.comments.add(new Comment(id, sourceId, range(u,a,b), lexical, raw, encoding, length, truncated));
174                        if (!"line".equals(lexical) && !"block".equals(lexical)) continue;
175                        CommentBinder.Choice choice = binder.bind(token,u.base,container.end,container);
176                        if (choice == null) diagnostic(out,"NO_HOST",sourceId,id,null);
177                        else if (choice.host == null) diagnostic(out,choice.rule,sourceId,id,null);
178                        else out.bindings.add(new Binding(id,choice.host.id,choice.placement,choice.rule));
179                    }
180                    String state = (limited || option.isIgnoreCoordinate()) ? "limited" : p.partial ? "parse_partial" : u.derived ? "unsupported" : "complete";
181                    for (String kind : KINDS) out.coverage.add(new Coverage(sourceId,kind,state));
182                    if (!"complete".equals(state)) out.state = "partial";
183                    if (limited) diagnostic(out,"LIMIT_REACHED",sourceId,null,null);
184                    if (option.isIgnoreCoordinate()) diagnostic(out,"COORDINATES_DISABLED",sourceId,null,null);
185                    if (u.derived) diagnostic(out,"SOURCE_MAP_UNAVAILABLE",sourceId,null,null);
186                }
187            }
188            for (Unit u : units) if (!processed.contains(u)) {
189                for (String kind : KINDS) out.coverage.add(new Coverage(u.source.id, kind, "parse_partial"));
190                out.state = "partial";
191                diagnostic(out, "PARSE_UNAVAILABLE", u.source.id, null, null);
192            }
193            if (missingSource) diagnostic(out,"SOURCE_MAP_UNAVAILABLE",null,null,null);
194            if (failed) { out.state = "failed"; diagnostic(out,"COLLECTION_FAILED",null,null,null); }
195        } catch (Exception e) { out.state = "failed"; diagnostic(out,"COLLECTION_FAILED",null,null,null); }
196        finally { clear(); }
197        return out;
198    }
199    private Range range(Unit u,int a,int b) { return option.isIgnoreCoordinate() ? null : u.index.range(a-u.base,b-u.base); }
200    private static void diagnostic(CommentBindingBundle b,String code,String source,String comment,String host) {
201        b.diagnostics.add(new Diagnostic(code,source,comment,host));
202        if (code.equals("INVALID_INPUT") || code.equals("SOURCE_MAP_UNAVAILABLE")) b.state = "partial";
203    }
204    private static String lexicalKind(String raw) {
205        if (raw.startsWith("/*+")) return "hint";
206        if (raw.startsWith("/*!")) return "executable";
207        if (raw.startsWith("/*M!")) return "executable";
208        if (raw.startsWith("--+")) return "hint";
209        if (raw.startsWith("-- " + TBaseType.sqlflow_stmt_delimiter_str)) return "directive";
210        if (raw.startsWith("--") && ENGINE_DIRECTIVE.matcher(raw).find()) return "directive";
211        if (raw.startsWith("--#") || raw.startsWith("/*#")) return "directive";
212        return raw.startsWith("/*") ? "block" : "line";
213    }
214    private static boolean invalidSurrogate(String s) {
215        for (int i=0;i<s.length();i++) {
216            char c=s.charAt(i);
217            if (Character.isHighSurrogate(c)) { if (++i>=s.length() || !Character.isLowSurrogate(s.charAt(i))) return true; }
218            else if (Character.isLowSurrogate(c)) return true;
219        }
220        return false;
221    }
222    private static boolean xmlSafe(String s) {
223        for (int i=0;i<s.length();i++) { char c=s.charAt(i); if ((c<32 && c!='\t' && c!='\r' && c!='\n') || c==0xfffe || c==0xffff) return false; }
224        return true;
225    }
226    private static void copyTargets(List<Target> to,List<Target> from) {
227        if (from != null) for (Target t : from) {
228            boolean found = false;
229            for (Target old : to) if (old.space.equals(t.space) && old.id.equals(t.id) && Objects.equals(old.parentId,t.parentId)) { found=true; break; }
230            if (!found) to.add(t);
231        }
232    }
233    private static void addTarget(IdentityHashMap<Object,List<Target>> map,Object key,Target target) {
234        if (key != null) map.computeIfAbsent(key,k->new ArrayList<Target>()).add(target);
235    }
236    private static void observe(IdentityHashMap<Object,List<Target>> map,Object key,Object value) {
237        if (key instanceof Pair) key = ((Pair<?,?>)key).second;
238        if (value instanceof List) { for (Object v:(List<?>)value) observe(map,key,v); return; }
239        Target target = null;
240        if (value instanceof Table) target = new Target("table",String.valueOf(((Table)value).getId()),null);
241        else if (value instanceof ResultSet) target = new Target("resultset",String.valueOf(((ResultSet)value).getId()),null);
242        else if (value instanceof TableColumn) { TableColumn c=(TableColumn)value; target=new Target("column",String.valueOf(c.getId()),String.valueOf(c.getTable().getId())); }
243        else if (value instanceof ResultColumn) { ResultColumn c=(ResultColumn)value; target=new Target("column",String.valueOf(c.getId()),String.valueOf(c.getResultSet().getId())); }
244        else if (value instanceof Procedure) { Procedure p=(Procedure)value; target=new Target("procedure",String.valueOf(p.getId()),null); addTarget(map,p.getProcedureObject(),target); }
245        else if (value instanceof OraclePackage) { OraclePackage p=(OraclePackage)value; target=new Target("package",String.valueOf(p.getId()),null); addTarget(map,p.getProcedureObject(),target); }
246        else if (value instanceof gudusoft.gsqlparser.dlineage.dataflow.model.Process) target=new Target("process",String.valueOf(((gudusoft.gsqlparser.dlineage.dataflow.model.Process)value).getId()),null);
247        if (target != null && key instanceof TParseTreeNode) addTarget(map,key,target);
248    }
249}