#!/usr/bin/env python3
"""selector_lint.py - read-only CSS selector checker and specificity calculator.
Usage:
  selector_lint.py FILE.css|FILE.html [...] [--legacy] [--strict]
  selector_lint.py --spec 'SELECTOR'
Standard library only. Reads files, writes nothing, prints terse lines, exit 1 on errors
(or on warnings with --strict). Does not execute scripts or resolve the cascade."""
import re, sys

PSEUDO_EL = re.compile(r"::|:(before|after|first-line|first-letter)\b")

def strip(css):
    css = re.sub(r"/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group()), css, flags=re.S)
    return css

def blocks(text, is_html):
    if not is_html:
        return [(text, 0)]
    out = []
    for m in re.finditer(r"<style[^>]*>(.*?)</style>", text, re.S | re.I):
        out.append((m.group(1), text.count("\n", 0, m.start(1))))
    return out

def preludes(css, base_line):
    """Yield (line, prelude) for every '{' at any depth. Skips at-rule preludes."""
    depth_paren, quote, start, line = 0, None, 0, base_line + 1
    start_line = line
    i = 0
    while i < len(css):
        c = css[i]
        if c == "\n": line += 1
        if quote:
            if c == "\\": i += 1
            elif c == quote: quote = None
        elif c in "\"'": quote = c
        elif c == "(" or c == "[": depth_paren += 1
        elif c == ")" or c == "]": depth_paren -= 1
        elif c == ";" and depth_paren == 0:
            start = i + 1; start_line = line
        elif c == "}" and depth_paren == 0:
            start = i + 1; start_line = line
        elif c == "{" and depth_paren == 0:
            p = css[start:i]
            lead = len(p) - len(p.lstrip())
            ln = start_line + p[:lead].count("\n")
            p = p.strip()
            if p and not p.startswith("@"):
                yield ln, p
            start = i + 1; start_line = line
        i += 1

def split_top(s, sep=","):
    parts, depth, cur, quote = [], 0, "", None
    for c in s:
        if quote:
            cur += c
            if c == quote: quote = None
            continue
        if c in "\"'": quote = c
        if c in "([": depth += 1
        elif c in ")]": depth -= 1
        if c == sep and depth == 0:
            parts.append(cur); cur = ""
        else:
            cur += c
    parts.append(cur)
    return [p.strip() for p in parts if p.strip()]

def args_of(sel, name):
    """Return list of argument strings for every :name( ... ) in sel (outermost)."""
    res, i = [], 0
    pat = ":" + name + "("
    while True:
        i = sel.find(pat, i)
        if i < 0: return res
        j, d = i + len(pat), 1
        while j < len(sel) and d:
            d += (sel[j] == "(") - (sel[j] == ")"); j += 1
        res.append(sel[i + len(pat): j - 1]); i = j

def spec(sel):
    """(ids, classes, types) per Selectors 4. :is/:not/:has = most specific argument,
    :where = 0, :nth-child(.. of S) = pseudo-class + S."""
    a = b = c = 0
    i, n = 0, len(sel)
    while i < n:
        ch = sel[i]
        if ch == "#": a += 1; i += 1; i = ident_end(sel, i)
        elif ch == "." : b += 1; i += 1; i = ident_end(sel, i)
        elif ch == "[":
            j = sel.find("]", i); b += 1; i = (j if j > 0 else n) + 1
        elif ch == ":":
            dbl = sel.startswith("::", i)
            j = i + (2 if dbl else 1); k = ident_end(sel, j); name = sel[j:k].lower(); i = k
            arg = None
            if i < n and sel[i] == "(":
                d, s0 = 1, i + 1; i += 1
                while i < n and d:
                    d += (sel[i] == "(") - (sel[i] == ")"); i += 1
                arg = sel[s0:i - 1]
            if dbl or name in ("before", "after", "first-line", "first-letter"): c += 1
            elif name in ("is", "not", "has", "matches", "-webkit-any"):
                m = max([spec(x.lstrip(">+~ ")) for x in split_top(arg or "")] or [(0, 0, 0)])
                a, b, c = a + m[0], b + m[1], c + m[2]
            elif name == "where": pass
            elif name in ("nth-child", "nth-last-child") and arg and " of " in arg:
                m = spec(arg.split(" of ", 1)[1]); a, b, c = a + m[0], b + 1 + m[1], c + m[2]
            else: b += 1
        elif ch in " >+~,\t\n*&|": 
            if ch == "&": pass
            i += 1
        elif ch.isalpha() or ch in "_-\\": c += 1; i = ident_end(sel, i)
        else: i += 1
    return (a, b, c)

def ident_end(s, i):
    while i < len(s) and (s[i].isalnum() or s[i] in "-_" or ord(s[i]) > 127 or s[i] == "\\"):
        i += 2 if s[i] == "\\" else 1
    return i

def lint(path, legacy):
    text = open(path, encoding="utf-8", errors="replace").read()
    out = []
    for css, base in blocks(text, path.lower().endswith((".html", ".htm"))):
        css = strip(css)
        for ln, prelude in preludes(css, base):
            for sel in split_top(prelude):
                for body in args_of(sel, "has"):
                    if ":has(" in body:
                        out.append((ln, "error", "has-nested", sel, ":has() cannot contain :has(). Chain :has(a):has(b) for AND, :has(a, b) for OR"))
                    if PSEUDO_EL.search(body):
                        out.append((ln, "error", "has-pseudo-element", sel, "pseudo-elements are invalid inside :has(). Move the test to a real element or class"))
                    if ":visited" in body:
                        out.append((ln, "error", "has-visited", sel, ":visited inside :has() always fails. Drop it"))
                    head = sel.split(":has(", 1)[0].strip()
                    last = re.split(r"[\s>+~]+", head)[-1] if head else ""
                    if last in ("", "*", "html", "body", ":root"):
                        out.append((ln, "warn", "has-broad-anchor", sel, "anchor is " + (last or "empty") + ", so the browser checks the whole page on change. Anchor on a component class"))
                    elif not re.match(r"\s*[>+~]", body):
                        out.append((ln, "info", "has-descendant", sel, "unbounded descendant test. Use > or + inside :has() if the relation is direct"))
                if legacy and ":has(" in sel and len(split_top(prelude)) > 1 and not re.match(r":(is|where)\(", prelude):
                    out.append((ln, "warn", "has-in-list", sel, "an engine without :has() drops the whole list. Put it in its own rule or inside :is()"))
                for fn in ("is", "not"):
                    for body in args_of(sel, fn):
                        parts = [spec(x) for x in split_top(body)]
                        if any(p[0] for p in parts):
                            out.append((ln, "warn", fn + "-id-specificity", sel, ":" + fn + "() takes its most specific argument; an id makes the whole selector id-weight. Use [id=\"x\"] or drop the id"))
                        elif len(set(parts)) > 1 and fn == "is":
                            out.append((ln, "info", "is-uneven-list", sel, "list members differ in specificity; the heaviest applies to all. Split the rule or use :where()"))
            if len(prelude) > 0 and re.search(r":matches\(|:-webkit-any\(|:-moz-any\(", prelude):
                out.append((ln, "info", "legacy-any", prelude, "vendor/early alias. Use :is()"))
    return out

def main(argv):
    if "--spec" in argv:
        s = argv[argv.index("--spec") + 1]
        print("(%d,%d,%d)  %s" % (*spec(s), s)); return 0
    legacy, strict = "--legacy" in argv, "--strict" in argv
    files = [a for a in argv if not a.startswith("--")]
    if not files:
        print(__doc__); return 2
    rows = []
    for f in files:
        try: rows += [(f,) + r for r in lint(f, legacy)]
        except OSError as e: print("cannot read %s: %s" % (f, e)); return 2
    order = {"error": 0, "warn": 1, "info": 2}
    bad = {(r[0], r[1], r[4]) for r in rows if r[2] != "info"}
    rows = [r for r in rows if r[2] != "info" or (r[0], r[1], r[4]) not in bad]
    rows.sort(key=lambda r: (order[r[2]], r[0], r[1]))
    cnt = {k: sum(1 for r in rows if r[2] == k) for k in order}
    print("selector_lint: %d error, %d warn, %d info in %d file(s)" % (cnt["error"], cnt["warn"], cnt["info"], len(files)))
    for f, ln, lvl, code, sel, fix in rows:
        print("%s:%d %s %s | %s | %s" % (f, ln, lvl, code, sel[:70], fix))
    return 1 if cnt["error"] or (strict and cnt["warn"]) else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
