"""Parsing for the indexed_range argument syntax. An indexed_range selects positions in one dimension of a grid, with an optional extent in the other dimension. The same syntax serves the table klammer's :hline and :vline arguments (index = boundary, subsets = how far along the line) and its :colspan and :rowspan arguments (index = row or column, subsets = the cells to merge). Which dimension the index selects is a property of the argument, not of the syntax. This module replaces the former sequences.py and span.py (see debris). """ import re syntax_description = """ An indexed_range is a selector, optionally followed by parenthesized subsets, written with no spaces: full extent () restricted extent The selector is a single index "3", a closed index range "2-5", an open index range "2-" (to the last index), or a name defined by the argument (for example "top" or "inner" for table lines). Subsets are separated by commas; each is an index "4", a closed range "1-4", or an open range "6-" (to the end). All indices are zero-origin. A negative index counts from the end, as in Python: -1 is the last index, -2 the second to last, and so on. Ranges are inclusive, so "1--2" is index 1 through the second-to-last index (the last index excluded). Examples: 3 index 3, full extent 2-5(0-2) indices 2 through 5, each restricted to 0 through 2 3(1-4,6-9) index 3, restricted to 1-4 and 6-9 -1 the last index 1--2 index 1 through the second-to-last index head(1-) with table hline names: boundary 1, from column 1 on """.strip() class Range_error(Exception): def __init__(self, message): super().__init__(f"{message}\n\n{syntax_description}") # A numeric selector is a signed integer, optionally followed by a range # part: a separating hyphen and an optional signed end index (empty end = # open range). The leading sign lets an index count from the end (-1 is # the last), matching Python list indexing. The separating hyphen never # collides with a minus sign because \d+ never consumes it. item_rgx = re.compile(r"(?:(-?\d+)(-(-?\d+)?)?|([A-Za-z]+))(?:\(([-\d,]+)\))?$") subset_rgx = re.compile(r"(-?\d+)(-(-?\d+)?)?$") def hline_names(count): """Boundary-name map for horizontal lines; count = row_count + 1.""" last = count - 1 return {"top": [0], "head": [1], "bottom": [last], "inner": list(range(1, last)), "all": list(range(count)), "none": []} def vline_names(count): """Boundary-name map for vertical lines; count = column_count + 1.""" last = count - 1 return {"outer": [0, last], "inner": list(range(1, last)), "all": list(range(count)), "none": []} class Indexed_range: """The selected extent for one primary-dimension index.""" def __init__(self, index, maxval): self.index = index self.maxval = maxval self.all = False # Full extent (no subsets given) self.ranges = [] # [[start, end], ...], inclusive def add_full(self): self.all = True self.ranges = [[0, self.maxval]] def add_ranges(self, ranges): if not self.all: self.ranges += ranges def has(self, i): return any(start <= i <= end for start, end in self.ranges) def items(self, invert=False): result = [] for start, end in self.ranges: for e in range(start, end + 1): result.append((e, self.index) if invert else (self.index, e)) return result def __str__(self): subsets = ",".join([f"{s}-{e}" for s, e in self.ranges]) return f"{self.index}({subsets})" def __repr__(self): return self.__str__() class Indexed_ranges: """A parsed indexed_range argument: Indexed_range entries by index. count - number of valid primary indices (0 .. count-1) maxval - largest valid subset value (the cross dimension) specs - the argument value: a list of items (from the argtype's python_cast), a whitespace-separated string, or None names - map of selector names to index lists (hline_names, ...) argument - argument name for error messages (":hline", ...) Items targeting the same index merge: their subsets are unioned, and a full-extent item absorbs any subsets. """ def __init__(self, count, maxval, specs, names=None, argument=""): self.count = count self.maxval = maxval self.names = names or {} self.argument = argument self.by_index = {} if specs is None: specs = [] elif isinstance(specs, str): specs = specs.split() for spec in specs: self.parse(spec) def error(self, message): argument = f"{self.argument} argument: " if self.argument else "" raise Range_error(f"{argument}{message}") def normalize(self, raw, spec, count): """Resolve a possibly-negative index to 0..count-1 (Python-style): a negative index counts from the end (-1 is the last).""" i = raw + count if raw < 0 else raw if not 0 <= i < count: self.error(f'In "{spec}", index {raw} is out of range ' f"(0 through {count - 1}, or -1 through -{count}).") return i def parse(self, spec): match = item_rgx.match(spec) if not match: self.error(f'"{spec}" is not a valid indexed_range.') number, range_part, end, name, subsets = match.groups() if name is not None: if name not in self.names: known = " ".join(self.names) or "none" self.error(f'"{name}" is not a valid name here ' f"(valid names: {known}).") indices = self.names[name] elif range_part is None: indices = [self.normalize(int(number), spec, self.count)] else: first = self.normalize(int(number), spec, self.count) last = (self.normalize(int(end), spec, self.count) if end else self.count - 1) if first > last: self.error(f'In "{spec}", the index range start {first} ' f"is greater than its end {last}.") indices = list(range(first, last + 1)) ranges = self.parse_subsets(spec, subsets) if subsets else None for i in indices: entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval)) if ranges is None: entry.add_full() else: entry.add_ranges(ranges) def parse_subsets(self, spec, subsets): # Subset indices run 0..maxval inclusive, so their count is # maxval + 1 and a negative subset index resolves against it. count = self.maxval + 1 ranges = [] for part in subsets.split(","): match = subset_rgx.match(part) if not match: self.error(f'In "{spec}", "{part}" is not a valid subset.') number, range_part, end = match.groups() if range_part is None: start = last = self.normalize(int(number), spec, count) else: start = self.normalize(int(number), spec, count) last = (self.normalize(int(end), spec, count) if end else self.maxval) if start > last: self.error(f'In "{spec}", the subset start {start} ' f"is greater than its end {last}.") ranges.append([start, last]) return ranges def __getitem__(self, index): return self.by_index.get(index) def __iter__(self): return iter(self.by_index) def has(self, index, i): entry = self[index] return entry.has(i) if entry else False def __str__(self): return " ".join([str(self.by_index[i]) for i in sorted(self.by_index)]) def __repr__(self): return self.__str__()