NormalizeNpsMemberOrderTransform.java

/*******************************************************************************
 * Copyright (c) 2025 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.queryrender.sparql.ir.util.transform;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import org.eclipse.rdf4j.queryrender.sparql.ir.IrBGP;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrGraph;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrMinus;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrNode;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrOptional;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrPathTriple;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrService;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrSubSelect;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrUnion;

/**
 * Normalize members inside negated property sets within path texts for stability. Currently preserves original member
 * order from the source while ensuring consistent token formatting. If future requirements need a specific ordering
 * (e.g., non-inverse before inverse, then lexical), that logic can be implemented in reorderMembers().
 */
public final class NormalizeNpsMemberOrderTransform extends BaseTransform {

	private NormalizeNpsMemberOrderTransform() {
	}

	public static IrBGP apply(IrBGP bgp) {
		if (bgp == null) {
			return null;
		}
		final List<IrNode> out = new ArrayList<>();
		for (IrNode n : bgp.getLines()) {
			IrNode m = n;
			if (n instanceof IrPathTriple) {
				IrPathTriple pt = (IrPathTriple) n;
				String ptxt = pt.getPathText();
				String rew = reorderAllNps(ptxt);
				if (!rew.equals(ptxt)) {
					IrPathTriple np = new IrPathTriple(pt.getSubject(), rew, pt.getObject(), pt.isNewScope(),
							pt.getPathVars());
					m = np;
				}
			} else if (n instanceof IrGraph) {
				IrGraph g = (IrGraph) n;
				m = new IrGraph(g.getGraph(), apply(g.getWhere()), g.isNewScope());
			} else if (n instanceof IrOptional) {
				IrOptional o = (IrOptional) n;
				IrOptional no = new IrOptional(apply(o.getWhere()), o.isNewScope());
				no.setNewScope(o.isNewScope());
				m = no;
			} else if (n instanceof IrMinus) {
				IrMinus mi = (IrMinus) n;
				m = new IrMinus(apply(mi.getWhere()), mi.isNewScope());
			} else if (n instanceof IrUnion) {
				IrUnion u = (IrUnion) n;
				IrUnion u2 = new IrUnion(u.isNewScope());
				for (IrBGP b : u.getBranches()) {
					u2.addBranch(apply(b));
				}
				m = u2;
			} else if (n instanceof IrService) {
				IrService s = (IrService) n;
				m = new IrService(s.getServiceRefText(), s.isSilent(), apply(s.getWhere()), s.isNewScope());
			} else if (n instanceof IrSubSelect) {
				// keep as-is
			}
			out.add(m);
		}
		return BaseTransform.bgpWithLines(bgp, out);
	}

	static String reorderAllNps(String path) {
		if (path == null || path.indexOf('!') < 0) {
			return path;
		}
		String s = path;
		StringBuilder out = new StringBuilder(s.length());
		int i = 0;
		while (i < s.length()) {
			int bang = s.indexOf("!(", i);
			if (bang < 0) {
				out.append(s.substring(i));
				break;
			}
			out.append(s, i, bang);
			int start = bang + 2;
			int j = start;
			int depth = 1;
			while (j < s.length() && depth > 0) {
				char c = s.charAt(j++);
				if (c == '(') {
					depth++;
				} else if (c == ')') {
					depth--;
				}
			}
			if (depth != 0) {
				// unmatched, bail out
				out.append(s.substring(bang));
				break;
			}
			int end = j - 1; // position of ')'
			String inner = s.substring(start, end);
			String reordered = reorderMembers(inner);
			out.append("!(").append(reordered).append(")");
			i = end + 1; // advance past the closing ')'
		}
		return out.toString();
	}

	static String reorderMembers(String inner) {
		class Tok {
			final String text; // original token (may start with '^')

			Tok(String t) {
				this.text = t;
			}
		}

		List<Tok> toks = Arrays.stream(inner.split("\\|"))
				.map(String::trim)
				.filter(t -> !t.isEmpty())
				.map(Tok::new)
				.collect(Collectors.toList());

		return toks.stream().map(t -> t.text).collect(Collectors.joining("|"));
	}

}