SourceFormatter.java

package dev.civl.abc.token.IF;

import java.util.ArrayList;

/**
 * Methods for printing source information in various related tools.
 */
public class SourceFormatter {

	/**
	 * Always use plain text output? If this is true, only ASCII text will be
	 * used for diagnostics. Otherwise, special color characters will be
	 * inserted *if* a console (terminal) is detected as the output device. If
	 * the output device is not a terminal, plain text will be used in any case.
	 */
	public final static boolean plain = false;

	/**
	 * After quoted source exceeds this number of characters, the quote will be
	 * elided using an ellipsis ("...")
	 */
	public final static int MAX_SOURCE_CHARS = 80;

	private final static String ANSI_RESET = "\u001B[0m";
	private final static String ANSI_BOLD = "\u001B[1m";
	private final static String ANSI_BLUE = "\u001B[34m";
	private final static String ANSI_RED = "\u001B[31m";

	/**
	 * List of source segments. Each segment is formed my joining adjacent
	 * tokens with a similar formation history. A null entry in this list
	 * indicates an elision which will be represented by an ellipsis "..."
	 */
	private ArrayList<SourceSegment> segments = new ArrayList<>();

	/**
	 * Locators are strings of the form "filename:nnn:mmm-mmm" which indicate a
	 * location in a source file. This code is inserted before a locator to
	 * highlight it properly.
	 */
	private static String openLocator() {
		return plain || System.console() == null ? "" : ANSI_BOLD;
	}

	private static String closeLocator() {
		return plain || System.console() == null ? "" : ANSI_RESET;
	}

	/**
	 * This code is placed before source code excerpts to highlight the code
	 * properly.
	 */
	public static String openSource() {
		return plain || System.console() == null ? "" : ANSI_BLUE;
	}

	public static String closeSource() {
		return plain || System.console() == null ? "" : ANSI_RESET;
	}

	/**
	 * This code is placed before an error message to highlight it properly.
	 */
	public static String openErr() {
		return plain || System.console() == null ? "" : ANSI_RED;
	}

	public static String closeErr() {
		return plain || System.console() == null ? "" : ANSI_RESET;
	}

	private static String escape(String txt) {
		txt = txt.replaceAll("\n", "\\\\n");
		txt = txt.replaceAll("\r", "\\\\r");
		txt = txt.replaceAll("\t", "\\\\t");
		return txt;
	}

	private static String clearWhiteSpace(String txt) {
		txt = txt.replaceAll("\n", " ");
		txt = txt.replaceAll("\r", "");
		txt = txt.replaceAll("\t", " ");
		txt = txt.strip();
		return txt;
	}

	public static String quoteSource(String text, boolean clear) {
		if (clear)
			return openSource() + clearWhiteSpace(text) + closeSource();
		else
			return openSource() + escape(text) + closeSource();
	}

	public static void addLocator(StringBuffer buf, String filename, int lineno,
			int startCol, int stopCol) {
		buf.append(openLocator());
		buf.append(filename);
		buf.append(":" + lineno + ":" + startCol);
		if (stopCol != startCol)
			buf.append("-" + stopCol);
		buf.append(closeLocator());
	}

	public static void addLocator(StringBuffer buf, String filename, int lineno,
			int startCol) {
		buf.append(openLocator());
		buf.append(filename);
		buf.append(":" + lineno + ":" + startCol);
		buf.append(closeLocator());
	}

	public static void addLocator(StringBuffer buf, String filename,
			int lineno) {
		buf.append(openLocator());
		buf.append(filename);
		buf.append(":" + lineno);
		buf.append(closeLocator());
	}

	public static String locator(String filename, int lineno, int startCol,
			int stopCol) {
		StringBuffer buf = new StringBuffer();
		addLocator(buf, filename, lineno, startCol, stopCol);
		return buf.toString();
	}

	public static String locator(String filename, int lineno, int startCol) {
		StringBuffer buf = new StringBuffer();
		addLocator(buf, filename, lineno, startCol);
		return buf.toString();
	}

	public static String locator(String filename, int lineno) {
		StringBuffer buf = new StringBuffer();
		addLocator(buf, filename, lineno);
		return buf.toString();
	}

	/**
	 * Put string in the right color/format for an error message.
	 * 
	 * @param str
	 *                a non-null String
	 * @return a new String with same content as original except for
	 *         color/formatting
	 */
	public static String errorify(String str) {
		return openErr() + str + closeErr();
	}

	/**
	 * Length of string excluding hidden characters.
	 * 
	 * @param str
	 *                a non-null String
	 * @return apparent length
	 */
	private static int apparentLength(String str) {
		int n = str.length();
		int result = 0;
		for (int i = 0; i < n; i++) {
			char c = str.charAt(i);
			if (c == '\u001B') {
				i += 3;
				continue;
			}
			result++;
		}
		return result;
	}

	public SourceFormatter(CivlcToken first, CivlcToken last) {
		int firstIndex = first.getIndex();
		int lastIndex = last.getIndex();
		if (firstIndex < 0)
			return;
		if (lastIndex < 0)
			return;
		if (firstIndex > lastIndex)
			return;
		CivlcToken token = first;
		SourceSegment seg = null;
		int mass = 0;
		for (int i = firstIndex; i < lastIndex; i++) {
			if (token == null) { // stream ended early
				segments.add(null); // eliding i .. lastIndex-1
				seg = null;
				break;
			}
			if (seg == null || !seg.add(token)) {
				if (mass >= MAX_SOURCE_CHARS) {
					segments.add(null);
					seg = null;
					break;
				}
				seg = new SourceSegment();
				seg.add(token);
				segments.add(seg);
			}
			mass += token.getText().length();
			token = token.getNext();
		}
		if (seg == null || !seg.add(last)) {
			seg = new SourceSegment();
			seg.add(last);
			segments.add(seg);
		}
	}

	public void addContent(StringBuffer buf) {
		buf.append(openSource());
		boolean first = true;
		for (SourceSegment seg : segments) {
			if (seg == null) {
				if (first)
					first = false;
				else
					buf.append(" ");
				buf.append("...");
				continue;
			}
			if (seg.theText.isBlank())
				continue;
			if (first)
				first = false;
			else
				buf.append(" ");
			buf.append(clearWhiteSpace(seg.theText));
		}
		buf.append(closeSource());
	}

	public String getContent() {
		StringBuffer buf = new StringBuffer();
		addContent(buf);
		return buf.toString();
	}

	public String getLocator(boolean abbrev) {
		if (segments.isEmpty())
			return "";
		SourceSegment seg0 = segments.get(0);
		String filename = abbrev
				? seg0.theFile.getIndexName()
				: seg0.theFilename;
		return SourceFormatter.locator(filename, seg0.theLineno,
				seg0.theStartCol);
	}

	public void getDetailedReport(StringBuffer buf) {
		if (segments.isEmpty())
			return;
		addContent(buf);
		buf.append("\n");
		// pass 1: get maximum column width of locators...
		int nseg = segments.size();
		int[] widths = new int[nseg];
		int locWidth = 0;
		for (int i = 0; i < nseg; i++) {
			SourceSegment seg = segments.get(i);
			if (seg != null) {
				String locator = locator(seg.theFilename, seg.theLineno,
						seg.theStartCol, seg.theStopCol);
				int width = apparentLength(locator);
				if (width > locWidth)
					locWidth = width;
				widths[i] = width;
			}
		}
		// pass 2: print...
		for (int i = 0; i < nseg; i++) {
			SourceSegment seg = segments.get(i);
			if (seg == null) {
				for (int j = 0; j < 3; j++)
					buf.append("     .\n");
			} else {
				addLocator(buf, seg.theFilename, seg.theLineno, seg.theStartCol,
						seg.theStopCol);
				for (int j = widths[i]; j < locWidth; j++)
					buf.append(" ");
				buf.append(" | ");
				String text = seg.theText;
				text = text.stripTrailing();
				buf.append(quoteSource(text, false));
				buf.append("\n");
				if (seg.theFormation != null) {
					String suffix = seg.theFormation.suffix();
					if (!suffix.isBlank()) {
						buf.append(" ");
						buf.append(suffix);
						buf.append("\n");
					}
				}
			}
		}

	}

	/**
	 * Produces a short locator string based on first token, followed by the
	 * complete text of the segments, all on one line.
	 * 
	 * @return single-line string representation of source
	 */
	public String getShortString(boolean abbrev) {
		if (segments.isEmpty())
			return "";
		SourceSegment seg0 = segments.get(0);
		StringBuffer buf = new StringBuffer();
		String filename = abbrev
				? seg0.theFile.getIndexName()
				: seg0.theFilename;
		SourceFormatter.addLocator(buf, filename, seg0.theLineno,
				seg0.theStartCol);
		buf.append(" ");
		addContent(buf);
		return buf.toString();
	}
}

/**
 * A segment is an accumulation of consecutive tokens that share the same
 * formation and come from one common line of a file. Their data can be merged
 * together to give a more concise output.
 */
class SourceSegment {
	boolean initialized = false;
	Formation theFormation = null;
	SourceFile theFile = null;
	String theFilename = null;
	String theText = "";
	int theLineno = -1;
	int theStartCol = -1;
	int theStopCol = -1;

	/**
	 * Add the given token's data to the current segment if that data is
	 * compatible with the segment. If it is compatible, the add succeeds and
	 * this method returns true, otherwise this method returns false without
	 * changing the segment.
	 * 
	 * @param token
	 *                  token to try to add to this segment
	 * @return true if add succeeds, else false
	 */
	boolean add(CivlcToken token) {
		Formation formation = token.getFormation();
		int lineno = token.getLine();
		SourceFile file = token.getSourceFile();
		int startCol = token.getCharPositionInLine();
		String text = token.getText();
		int stopCol = startCol + text.length() - 1;

		if (!initialized) {
			theFormation = formation;
			theFile = file;
			theFilename = file.getName();
			theLineno = lineno;
			theStartCol = startCol;
			theText = text;
			theStopCol = stopCol;
			initialized = true;
			return true;
		}
		if (!(formation == null
				? theFormation == null
				: formation.equals(theFormation)))
			return false;
		if (lineno != theLineno)
			return false;
		if (!(file == null ? theFile == null : file.equals(theFile)))
			return false;
		if (startCol < theStartCol)
			theStartCol = startCol;
		if (stopCol > theStopCol)
			theStopCol = stopCol;
		theText += text;
		return true;
	}
}