mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 04:29:27 +00:00
refactor(p1): one source-facts module owns function ranges, callers, declaration tables and language aliases
src/openultrasast/source.py replaces the copies in semantic/functions.py, plane/tasks/repo_facts.py, learn/features.py, learn/excerpt.py and plane/tasks/verify.py. Two engines share one sentinel (<module>), one DECLARATION/KEYWORDS/RESERVED set and one alias map: the parser engine (ranges, enclosing, bounds, call_sites, callers_of, facts_for) for the scorer, the overlay and the hunter's annotations; the line-pattern engine (declared_functions, enclosing_at, function_span, function_bounds, the brace matcher) that labels, features, roles, excerpts and plane facts were built on. learn no longer imports plane.tasks. Frozen records that store the old sentinel are mapped on read (module_function). Gate: labels rebuilt before and after are identical, 7,921 rows, after sentinel normalisation (benchmarks/measurements/2026-10-13-source-module- label-diff/record.json). 490 targeted tests pass; 3,312 collect. Net: 657 lines deleted, 212 added. benchmarks/unseen keeps <global> for its own frozen manifests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f63703a604
commit
062a7b780e
@@ -0,0 +1,739 @@
|
||||
"""Facts about repository source text, shared by scans, learning and plane tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from .preprocess import IGNORED_DIRS, build_file_target, detect_language
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .mapping import EntryPointRecord
|
||||
from .semantic.ir import FileIR
|
||||
|
||||
LANGUAGE_ALIASES: Mapping[str, str] = MappingProxyType(
|
||||
{
|
||||
"c_cpp": "cpp",
|
||||
"c++": "cpp",
|
||||
"python_web": "python",
|
||||
"py": "python",
|
||||
"node": "javascript",
|
||||
"js": "javascript",
|
||||
"ts": "typescript",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def module_function(name: str) -> str:
|
||||
"""Map the legacy module-level sentinel in stored records to the one name the code uses."""
|
||||
return MODULE_FUNCTION if name == LEGACY_MODULE_FUNCTION else name
|
||||
|
||||
|
||||
def canonical_language(name: str) -> str:
|
||||
"""Return the language name used by source parsers."""
|
||||
name = name.strip().lower()
|
||||
return LANGUAGE_ALIASES.get(name, name)
|
||||
|
||||
|
||||
MODULE_FUNCTION = "<module>"
|
||||
LEGACY_MODULE_FUNCTION = "<global>" # what frozen records before 2026-10-13 call module-level code
|
||||
ANON_FUNCTION = "<anon>"
|
||||
MAX_FILE_BYTES = 1_000_000
|
||||
LANGUAGES = frozenset({"python", "php", "javascript", "typescript", "java", "go", "ruby"}) # the declared languages
|
||||
_EXCLUDED_DIRS = frozenset(
|
||||
IGNORED_DIRS | {"vendor", "node_modules", "bower_components", "third_party", "third-party", "dist", "build", "out", "target"}
|
||||
)
|
||||
_NOT_PRODUCT = re.compile(
|
||||
r"(^|/)(tests?|__tests__|__mocks__|spec|specs|docs?|examples?|fixtures?|e2e|cypress|storybook|testdata)/"
|
||||
r"|\.(test|spec|stories|mock|e2e)\.[cm]?[jt]sx?$"
|
||||
r"|_test\.(py|go|rb)$|(^|/)test_[^/]*\.(py|rb)$|Test\.java$|Tests\.java$|_spec\.rb$"
|
||||
r"|\.min\.(js|css)$|\.bundle\.js$"
|
||||
)
|
||||
|
||||
_JS_MODIFIERS = r"(?:(?:export|default|public|private|protected|static|async|readonly|override)\s+)*"
|
||||
_JAVA_MODIFIERS = r"(?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*"
|
||||
DECLARATION: dict[str, re.Pattern[str]] = {
|
||||
"python": re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\("),
|
||||
"php": re.compile(r"^\s*(?:(?:public|private|protected|static|final|abstract)\s+)*function\s+&?(\w+)\s*\("),
|
||||
"javascript": re.compile(
|
||||
r"^\s*" + _JS_MODIFIERS + r"function\s*\*?\s*(\w+)\s*\("
|
||||
r"|^\s*" + _JS_MODIFIERS + r"(\w+)\s*\([^;]*\)\s*(?::[^{=]+)?\{\s*$"
|
||||
r"|^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::[^=]+)?=>|\w+\s*=>)"
|
||||
),
|
||||
"java": re.compile(
|
||||
r"^\s*" + _JAVA_MODIFIERS + r"(?:<[^>]*>\s+)?(?!return\b|new\b|throw\b)[\w<>\[\],.?]+\s+(\w+)\s*\([^;]*\)\s*"
|
||||
r"(?:throws\s+[\w.,\s]+)?\{?\s*$"
|
||||
),
|
||||
"go": re.compile(r"^\s*func\s+(?:\([^)]*\)\s*)?(\w+)\s*\("),
|
||||
"ruby": re.compile(r"^\s*def\s+(?:self\.)?(\w+[?!=]?)"),
|
||||
}
|
||||
DECLARATION["typescript"] = DECLARATION["javascript"]
|
||||
KEYWORDS = frozenset(
|
||||
{
|
||||
"if",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"return",
|
||||
"function",
|
||||
"else",
|
||||
"do",
|
||||
"try",
|
||||
"with",
|
||||
"elif",
|
||||
"except",
|
||||
"new",
|
||||
"throw",
|
||||
"super",
|
||||
"constructor",
|
||||
"foreach",
|
||||
"match",
|
||||
"case",
|
||||
"unless",
|
||||
"until",
|
||||
}
|
||||
)
|
||||
_COMMENT_PREFIXES = ("#", "//", "/*", "*", "*/", "--")
|
||||
|
||||
|
||||
RESERVED_FUNCTION_LABELS = KEYWORDS | frozenset(
|
||||
{
|
||||
"function",
|
||||
"def",
|
||||
"return",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"class",
|
||||
"async",
|
||||
"await",
|
||||
"lambda",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"export",
|
||||
"import",
|
||||
"module",
|
||||
"exports",
|
||||
"new",
|
||||
"this",
|
||||
"self",
|
||||
"anonymous",
|
||||
ANON_FUNCTION,
|
||||
}
|
||||
)
|
||||
|
||||
# KEYWORDS is the line engine's statement-word filter (a declaration named `class` or `self` is still a declaration
|
||||
# for the parser engine, which uses RESERVED_FUNCTION_LABELS). The two sets differ on purpose; see the label diff gate.
|
||||
|
||||
_IDENT = r"[A-Za-z_$][\w$]*"
|
||||
_ARROW_TAIL = r"(?:async\s*)?(?:\([^)]*\)|" + _IDENT + r")\s*=>"
|
||||
_DECL = re.compile(
|
||||
# def name( / function name( / export async function name(
|
||||
r"^\s*(?:export\s+)?(?:async\s+)?(?:def|function|sub)\s+(" + _IDENT + r")\s*[\({]|"
|
||||
# const name = [a.b.c =]* [async] (args) => | ident =>
|
||||
r"^\s*(?:export\s+)?(?:const|let|var)\s+(" + _IDENT + r")\s*=\s*(?:" + _IDENT + r"(?:\." + _IDENT + r")*\s*=\s*)*" + _ARROW_TAIL + r"|"
|
||||
# exports.name = / module.exports.name = [async] function | (args) =>
|
||||
r"^\s*(?:module\.)?exports\.(" + _IDENT + r")\s*=\s*(?:async\s*)?(?:function\b|" + _ARROW_TAIL + r")|"
|
||||
# object property: name: [async] function | (args) =>
|
||||
r"^\s*(?:async\s+)?(" + _IDENT + r")\s*:\s*(?:async\s*)?(?:function\b|" + _ARROW_TAIL + r")|"
|
||||
# C / Java / JS method or shorthand: [type] name(args) { (no call: the arg list carries no callback or literal)
|
||||
r"^\s*(?:[A-Za-z_][\w:<>\*\s,]*[\s\*])?(" + r"[A-Za-z_][\w:$]*" + r")\s*\(([^;{]*)\)\s*(?:const\s*)?\{?\s*$"
|
||||
)
|
||||
_CALLBACK_ARGS = re.compile(r"\bfunction\b|=>|['\"`]|^\s*\d")
|
||||
# A statement keyword before `name(` means the line calls or controls, it does not declare.
|
||||
_STATEMENT_PREFIX = re.compile(
|
||||
r"^\s*(?:return|print|yield|throw|await|new|else|case|delete|typeof|synchronized|with|elif|except|try|if|for|while|switch|catch|do)\b"
|
||||
)
|
||||
# Only these grammars name an arrow function after its bare parameter; elsewhere the parser name is trustworthy.
|
||||
_PARAM_NAMED_ARROWS = frozenset({"javascript", "typescript"})
|
||||
|
||||
|
||||
def declarator_name(line: str, language: str = "") -> str | None:
|
||||
"""Function name declared on ``line``, or None when the line declares nothing (a call is not a declaration)."""
|
||||
pattern = DECLARATION.get(canonical_language(language))
|
||||
if pattern is not None and (match := pattern.match(line)):
|
||||
name = next((group for group in match.groups() if group), "")
|
||||
if name and name not in RESERVED_FUNCTION_LABELS:
|
||||
return name
|
||||
if language in {"python", "ruby", "go", "php", "java"}:
|
||||
return None
|
||||
match = _DECL.match(line)
|
||||
if not match:
|
||||
return None
|
||||
groups = match.groups()
|
||||
name = next((group for group in groups[:5] if group), None)
|
||||
if name is None:
|
||||
return None
|
||||
if groups[4] is not None:
|
||||
args = groups[5] or ""
|
||||
if _CALLBACK_ARGS.search(args):
|
||||
return None # `exec('...', function (err) {` is a call, not a declaration
|
||||
if _STATEMENT_PREFIX.match(line) or name in {"if", "for", "while", "switch", "catch", "return", "elif", "with", "synchronized"}:
|
||||
return None
|
||||
name = name.rsplit("::", 1)[-1] # `bool K::m(` declares `m`; catalog labels are unqualified
|
||||
return name if name not in RESERVED_FUNCTION_LABELS else None
|
||||
|
||||
|
||||
class FunctionRange(NamedTuple):
|
||||
"""A function's name and inclusive, one-based source lines."""
|
||||
|
||||
name: str
|
||||
start: int
|
||||
end: int
|
||||
kind: str
|
||||
|
||||
|
||||
class CallSite(NamedTuple):
|
||||
"""A call's one-based line and the name it invokes."""
|
||||
|
||||
line: int
|
||||
name: str
|
||||
|
||||
|
||||
class SourceFacts(NamedTuple):
|
||||
"""Repository evidence about one source location."""
|
||||
|
||||
language: str
|
||||
function: str
|
||||
span: tuple[int, int] | None
|
||||
callers: tuple[tuple[str, int], ...]
|
||||
entry_points: tuple[EntryPointRecord, ...]
|
||||
|
||||
|
||||
def product_files(root: Path) -> list[str]:
|
||||
"""Return sorted repository-relative product paths, excluding tests and dependencies."""
|
||||
from .model.layout import is_test_path, is_vendored, layout_facts
|
||||
|
||||
root = Path(root)
|
||||
facts = layout_facts()
|
||||
files = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = path.relative_to(root)
|
||||
if any(part.startswith(".") or part in _EXCLUDED_DIRS for part in relative.parts):
|
||||
continue
|
||||
if path.is_symlink() or any(parent.is_symlink() for parent in path.parents if parent != root) or not path.is_file():
|
||||
continue
|
||||
posix = relative.as_posix()
|
||||
if _NOT_PRODUCT.search(posix) or is_vendored(posix, facts) or is_test_path(posix, facts):
|
||||
continue
|
||||
if detect_language(path) in LANGUAGES and path.stat().st_size <= MAX_FILE_BYTES:
|
||||
files.append(posix)
|
||||
return files
|
||||
|
||||
|
||||
def ranges_from_ir(ir: FileIR, text: str) -> tuple[FunctionRange, ...]:
|
||||
"""Use declarator names when a parser names a function after its parameter."""
|
||||
lines = text.splitlines()
|
||||
result = []
|
||||
for fn in ir.functions:
|
||||
if fn.name == MODULE_FUNCTION:
|
||||
continue
|
||||
line = lines[fn.start_line - 1] if 0 < fn.start_line <= len(lines) else ""
|
||||
declared = declarator_name(line, ir.language)
|
||||
name = declared or fn.name
|
||||
if (
|
||||
name in RESERVED_FUNCTION_LABELS
|
||||
and name != ANON_FUNCTION
|
||||
or declared is None
|
||||
and ir.language in _PARAM_NAMED_ARROWS
|
||||
and not _declares(line, name)
|
||||
):
|
||||
name = ANON_FUNCTION
|
||||
result.append(FunctionRange(name, fn.start_line, fn.end_line, "anonymous" if name == ANON_FUNCTION else "function"))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _declares(line: str, name: str) -> bool:
|
||||
escaped = re.escape(name)
|
||||
return bool(re.search(r"\b(?:function|def|sub)\s+" + escaped + r"\b|(?<![\w$.])" + escaped + r"\s*\(", line))
|
||||
|
||||
|
||||
def _code_lines(text: str) -> list[str]:
|
||||
"""Blank comments and quoted strings before counting delimiters."""
|
||||
token = re.compile(r"/\*[\s\S]*?\*/|//[^\n]*|\#[^\n]*|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`")
|
||||
return token.sub(lambda match: re.sub(r"[^\n]", " ", match.group()), text).splitlines()
|
||||
|
||||
|
||||
def _body_end(code: Sequence[str], start: int, column: int) -> int | None:
|
||||
"""Find the end of a body after its declaration head, skipping prototypes and calls."""
|
||||
parens, depth, opened, arrow = 0, 0, False, False
|
||||
for row in range(start, len(code)):
|
||||
for col in range(column if row == start else 0, len(code[row])):
|
||||
char = code[row][col]
|
||||
if opened:
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return row + 1
|
||||
elif char == "(":
|
||||
parens += 1
|
||||
elif char == ")":
|
||||
parens -= 1
|
||||
if parens < 0:
|
||||
return None
|
||||
elif parens == 0:
|
||||
if code[row].startswith("=>", col):
|
||||
arrow = True
|
||||
if char == "{":
|
||||
opened, depth = True, 1
|
||||
elif char in ";}":
|
||||
return row + 1 if arrow else None
|
||||
elif char == "," and arrow:
|
||||
return row + 1
|
||||
if not opened and arrow:
|
||||
return row + 1
|
||||
if not opened and row - start >= 40:
|
||||
return None
|
||||
return len(code) if opened else None
|
||||
|
||||
|
||||
_HEAD = re.compile(
|
||||
r"(?<![\w.$>])([A-Za-z_$][\w:$]*)\s*(?:<[^<>(){};]*>\s*)?(?=\()|^\s*sub\s+(\w+)\s*(?=\{)"
|
||||
r"|(?<![\w$])([A-Za-z_$][\w$]*)\s*[:=]\s*(?:[\w$.]+\s*=\s*)*(?:async\s*)?(?:function\b\s*[\w$]*\s*)?(?=\(|[\w$]+\s*=>)"
|
||||
)
|
||||
|
||||
|
||||
def _regex_ranges(text: str, language: str) -> tuple[FunctionRange, ...]:
|
||||
lines, code = text.splitlines(), _code_lines(text)
|
||||
result = []
|
||||
for index, line in enumerate(code):
|
||||
name = declarator_name(line, language)
|
||||
if language == "python":
|
||||
if not name:
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip())
|
||||
end = index + 1
|
||||
for row in range(index + 1, len(code)):
|
||||
if code[row].strip() and len(code[row]) - len(code[row].lstrip()) <= indent:
|
||||
break
|
||||
if code[row].strip():
|
||||
end = row + 1
|
||||
elif language == "ruby":
|
||||
if not name:
|
||||
continue
|
||||
depth, end = 1, len(lines)
|
||||
for row in range(index + 1, len(code)):
|
||||
if re.match(r"\s*(?:def|class|module|if|unless|case|begin|while|until|for)\b", code[row]):
|
||||
depth += 1
|
||||
if re.search(r"\bdo\s*(?:\|[^|]*\|)?\s*$", code[row]):
|
||||
depth += 1
|
||||
if re.match(r"\s*end\b", code[row]):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = row + 1
|
||||
break
|
||||
else:
|
||||
if _STATEMENT_PREFIX.match(line):
|
||||
continue
|
||||
heads = list(_HEAD.finditer(line))
|
||||
if not heads:
|
||||
continue
|
||||
head = heads[0]
|
||||
candidate = next(group for group in head.groups() if group).rsplit("::", 1)[-1]
|
||||
name = name or candidate
|
||||
if name in RESERVED_FUNCTION_LABELS:
|
||||
continue
|
||||
end = _body_end(code, index, head.end())
|
||||
if end is None:
|
||||
continue
|
||||
result.append(FunctionRange(name, index + 1, end, "function"))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def ranges(path: str, text: str, language: str) -> tuple[FunctionRange, ...]:
|
||||
"""Return inclusive source ranges using tree-sitter, or a declaration scan without a grammar.
|
||||
|
||||
A parser rejection raises ValueError so unreadable syntax cannot look like a file without functions; a language
|
||||
without a grammar uses the declaration scan.
|
||||
"""
|
||||
from .semantic.cst import parse_with_cst
|
||||
|
||||
language = canonical_language(language)
|
||||
ir = parse_with_cst(path, text, language)
|
||||
if ir is not None:
|
||||
if not ir.parse_ok and ir.reason != "language_unsupported":
|
||||
raise ValueError(f"{path}: source parse failed")
|
||||
named = ranges_from_ir(ir, text) if ir.parse_ok else _regex_ranges(text, language)
|
||||
else:
|
||||
named = _regex_ranges(text, language)
|
||||
module = (FunctionRange(MODULE_FUNCTION, 1, len(text.splitlines()), "module"),) if text.splitlines() else ()
|
||||
return module + named
|
||||
|
||||
|
||||
def enclosing(ranges: Sequence[FunctionRange], line: int | None) -> str:
|
||||
"""Return the innermost named function at a one-based line, or the module label."""
|
||||
found = [item for item in ranges if item.kind == "function" and line is not None and item.start <= line <= item.end]
|
||||
return min(found, key=lambda item: item.end - item.start).name if found else MODULE_FUNCTION
|
||||
|
||||
|
||||
def bounds(ranges: Sequence[FunctionRange], name: str) -> tuple[int, int] | None:
|
||||
"""Return the first matching function's inclusive, one-based bounds."""
|
||||
name = re.split(r"::|\.|->", name)[-1]
|
||||
return next(((item.start, item.end) for item in ranges if item.name == name), None)
|
||||
|
||||
|
||||
def spans_named(ranges: Sequence[FunctionRange], name: str) -> tuple[tuple[int, int], ...]:
|
||||
"""Return all ranges of a name, including repeated method declarations."""
|
||||
return tuple((item.start, item.end) for item in ranges if item.name == name)
|
||||
|
||||
|
||||
def call_sites(text: str, names: Iterable[str]) -> tuple[CallSite, ...]:
|
||||
"""Return calls in line order, excluding declarations and comments, once per name and line."""
|
||||
wanted = sorted(set(names) - RESERVED_FUNCTION_LABELS - {MODULE_FUNCTION}, key=lambda name: (-len(name), name))
|
||||
if not wanted:
|
||||
return ()
|
||||
token = re.compile(r"(?<![A-Za-z0-9_$])(" + "|".join(re.escape(name) for name in wanted) + r")\s*\(")
|
||||
result = []
|
||||
for number, line in enumerate(_code_lines(text), start=1):
|
||||
declared = {
|
||||
next((group for group in match.groups() if group), "") for pattern in DECLARATION.values() if (match := pattern.match(line))
|
||||
}
|
||||
if "{" in line or "=>" in line:
|
||||
declared.add(declarator_name(line))
|
||||
seen = set()
|
||||
for match in token.finditer(line):
|
||||
name = match.group(1)
|
||||
# A same-line body can call its own function after the declaration head.
|
||||
if name in seen or (name in declared and not any(char in line[: match.start()] for char in "{;")):
|
||||
continue
|
||||
seen.add(name)
|
||||
result.append(CallSite(number, name))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _read_source(root: Path, path: str) -> str:
|
||||
resolved = (root / path).resolve()
|
||||
if not resolved.is_relative_to(root.resolve()) or any(part.startswith(".env") for part in Path(path).parts):
|
||||
raise ValueError(f"source path is outside the repository or private: {path}")
|
||||
return resolved.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def callers_of(root: Path, name: str, *, files: Iterable[str]) -> tuple[tuple[str, int], ...]:
|
||||
"""Return sorted call locations from the supplied repository-relative product files."""
|
||||
root = Path(root)
|
||||
return tuple((path, call.line) for path in sorted(set(files)) for call in call_sites(_read_source(root, path), (name,)))
|
||||
|
||||
|
||||
def facts_for(root: Path, path: str, line: int, *, entry_points: Sequence[EntryPointRecord] | None = None) -> SourceFacts:
|
||||
"""Compute repository facts at a line; supplied entry points avoid repeated mapping."""
|
||||
root = Path(root)
|
||||
text = _read_source(root, path)
|
||||
language = canonical_language(detect_language(root / path))
|
||||
spans = ranges(path, text, language)
|
||||
function = enclosing(spans, line)
|
||||
files = product_files(root)
|
||||
if entry_points is None:
|
||||
from .mapping import analyze_entry_points
|
||||
|
||||
entry_points = analyze_entry_points(root, [build_file_target(root, root / file) for file in files])
|
||||
return SourceFacts(language, function, bounds(spans, function), callers_of(root, function, files=files), tuple(entry_points))
|
||||
|
||||
|
||||
def _digest(names: Sequence[str]) -> str:
|
||||
"""Identify a sorted set of function names for stored facts."""
|
||||
return hashlib.sha256("\n".join(names).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# --- the line-pattern engine: declarations by regular expression, spans to the next declaration, brace matching -----
|
||||
# This is what labels, features, roles, excerpts and the plane facts were built on. The parser-based `ranges` above
|
||||
# serves the scorer and the overlay; both share DECLARATION, KEYWORDS, RESERVED_FUNCTION_LABELS and the sentinel.
|
||||
|
||||
HASH_COMMENTS = frozenset({"python", "php", "ruby", "shell", "perl", "yaml", "r"})
|
||||
|
||||
SLASH_COMMENTS = frozenset(
|
||||
{"c", "cpp", "java", "javascript", "typescript", "php", "go", "groovy", "csharp", "kotlin", "rust", "swift", "scala"}
|
||||
) # fmt: skip
|
||||
|
||||
def _comment_ranges(line: str, state: str | None, language: str) -> tuple[list[tuple[int, int]], str | None]:
|
||||
"""The comment spans of one line and the block state carried to the next (``block`` inside ``/* */``, a triple
|
||||
quote inside a Python docstring). Strings are skipped so a ``#`` in a literal is not a comment."""
|
||||
hashes, slashes, python = language in HASH_COMMENTS, language in SLASH_COMMENTS, language == "python"
|
||||
ranges: list[tuple[int, int]] = []
|
||||
i, n, quote = 0, len(line), None
|
||||
if state is not None:
|
||||
close = "*/" if state == "block" else state
|
||||
j = line.find(close)
|
||||
if j < 0:
|
||||
return [(0, n)], state
|
||||
ranges.append((0, j + len(close)))
|
||||
i, state = j + len(close), None
|
||||
while i < n:
|
||||
c = line[i]
|
||||
if quote:
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
if python and line.startswith(('"""', "'''"), i) and not line[:i].strip():
|
||||
delim = line[i : i + 3]
|
||||
j = line.find(delim, i + 3)
|
||||
if j < 0:
|
||||
ranges.append((i, n))
|
||||
return ranges, delim
|
||||
ranges.append((i, j + 3))
|
||||
i = j + 3
|
||||
continue
|
||||
if c in "\"'`":
|
||||
quote = c
|
||||
elif (slashes and line.startswith("//", i)) or (hashes and c == "#"):
|
||||
ranges.append((i, n))
|
||||
break
|
||||
elif slashes and line.startswith("/*", i):
|
||||
j = line.find("*/", i + 2)
|
||||
if j < 0:
|
||||
ranges.append((i, n))
|
||||
return ranges, "block"
|
||||
ranges.append((i, j + 2))
|
||||
i = j + 2
|
||||
continue
|
||||
i += 1
|
||||
return ranges, state
|
||||
|
||||
_UNKNOWN_LANGUAGES = frozenset({"", "other", "unknown"})
|
||||
|
||||
_PERL_LINE = re.compile(r"^\s*(?:sub\s+\w+\s*[{(;]|use\s+strict\s*;|my\s+[$@%]\w+)")
|
||||
|
||||
_RUBY_DEF = re.compile(r"^\s*def\s+(?:self\.)?\w+[?!=]?(?:\s*\(|\s|$)")
|
||||
|
||||
_RUBY_END = re.compile(r"^\s*end\s*$")
|
||||
|
||||
_BODY_SCAN_LINES = 40 # how far past a declaration head the body's ``{`` (or a prototype's ``;``) may be
|
||||
|
||||
_STATEMENT_HEAD = re.compile(r"\s*(return|if|while|for|switch|else|case|throw|await|yield)\b")
|
||||
|
||||
_FUNCTION_EXPR = r"(?:async\s+)?(?:function\b\s*\*?\s*[\w$]*\s*)?(?=\(|[\w$]+\s*=>)" # function (...) / (...) => / a =>
|
||||
|
||||
def resolve_language(lines: Sequence[str], language: str) -> str:
|
||||
"""The alias-normalised language; a file given as unknown (``other``, the feature vocabulary's bucket for Go, Ruby,
|
||||
C#, Perl, ...) is sniffed for Perl (``sub name {``, ``use strict;``) and Ruby (``def`` ... ``end``), whose
|
||||
declaration forms a brace matcher never finds and whose ``#`` comments must be redacted."""
|
||||
language = canonical_language(language)
|
||||
if language in _UNKNOWN_LANGUAGES and lines:
|
||||
if any(_PERL_LINE.match(line) for line in lines):
|
||||
return "perl"
|
||||
if any(_RUBY_DEF.match(line) for line in lines) and any(_RUBY_END.match(line) for line in lines):
|
||||
return "ruby"
|
||||
return language
|
||||
|
||||
def _blank_comments(lines: Sequence[str], language: str) -> list[str]:
|
||||
style = language if language in SLASH_COMMENTS or language in HASH_COMMENTS else "c"
|
||||
code: list[str] = []
|
||||
state: str | None = None
|
||||
for line in lines:
|
||||
ranges, state = _comment_ranges(line, state, style)
|
||||
for start, end in reversed(ranges):
|
||||
line = line[:start] + " " * (end - start) + line[end:]
|
||||
code.append(line)
|
||||
return code
|
||||
|
||||
def _declaration_heads(name: str) -> re.Pattern[str]:
|
||||
"""Where a brace language may declare ``name`` (the body follows): ``name(`` (a definition -- or a call, told
|
||||
apart by :func:`_body_start`); Perl ``sub name {``; an object-literal method ``name: function (`` /
|
||||
``name: (a) =>``; an assignment ``x.y.name = function (`` / ``= (a) =>`` / ``const name = m.name = async a =>``
|
||||
(any chain). ``$`` is an identifier character (``Glance$serveRequest``)."""
|
||||
quoted = re.escape(name)
|
||||
return re.compile(
|
||||
rf"(?<![\w.>$]){quoted}\s*(?:<[^<>(){{}};]*>\s*)?(?=\()"
|
||||
rf"|^\s*sub\s+{quoted}\s*(?=[{{(;])"
|
||||
rf"|(?<![\w$]){quoted}\s*:\s*{_FUNCTION_EXPR}"
|
||||
rf"|(?<![\w$]){quoted}\s*=\s*(?:[\w$.\[\]'\"]+\s*=\s*)*{_FUNCTION_EXPR}"
|
||||
)
|
||||
|
||||
def _body_start(code: Sequence[str], line: int, column: int) -> tuple[str, int, int] | None:
|
||||
"""Scan from a declaration head for its body: ``("brace", line, column)`` of the ``{`` at parenthesis depth 0,
|
||||
``("expression", line, column)`` of the ``;`` or ``,`` ending an arrow function's expression body, ``None`` for a
|
||||
prototype (``;`` before any body), a call (a ``)`` closing an outer parenthesis, a ``}``) or no body within
|
||||
:data:`_BODY_SCAN_LINES`. Parentheses are tracked so ``f(a, function () {})`` is not a definition of ``f``."""
|
||||
depth, arrow = 0, False
|
||||
for j in range(line, min(line + _BODY_SCAN_LINES, len(code))):
|
||||
text = code[j]
|
||||
k = column if j == line else 0
|
||||
while k < len(text):
|
||||
c = text[k]
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
return None
|
||||
elif depth == 0:
|
||||
if c == "{":
|
||||
return ("brace", j, k)
|
||||
if arrow and c in ";,":
|
||||
return ("expression", j, k)
|
||||
if c in "};": # a statement ends (a call, a prototype) before any body opened
|
||||
return None
|
||||
if text.startswith("=>", k):
|
||||
arrow = True
|
||||
k += 1
|
||||
k += 1
|
||||
return None
|
||||
|
||||
def _brace_bounds(lines: Sequence[str], language: str, function: str) -> tuple[int, int] | None:
|
||||
"""A brace language's definition of ``function`` (``Class::name`` and ``a.b`` reduced to the last name): the first
|
||||
declaration head (:func:`_declaration_heads`) whose body :func:`_body_start` finds, to the matching ``}`` (or the
|
||||
end of an expression body). Comments are blanked before anything is matched or counted."""
|
||||
name = re.split(r"::|\.|->", function)[-1]
|
||||
if not re.fullmatch(r"[A-Za-z_$][\w$]*", name):
|
||||
return None
|
||||
heads = _declaration_heads(name)
|
||||
code = _blank_comments(lines, language)
|
||||
for i, line in enumerate(code):
|
||||
if _STATEMENT_HEAD.match(line):
|
||||
continue
|
||||
for found in heads.finditer(line):
|
||||
body = _body_start(code, i, found.end())
|
||||
if body is None:
|
||||
continue
|
||||
kind, j, k = body
|
||||
if kind == "expression":
|
||||
return i, j + 1
|
||||
depth = 0
|
||||
for row in range(j, len(code)):
|
||||
for c in code[row][k:] if row == j else code[row]:
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i, row + 1
|
||||
return i, len(code)
|
||||
return None
|
||||
|
||||
def function_bounds(lines: Sequence[str], language: str, function: str) -> tuple[int, int] | None:
|
||||
"""0-based ``[start, end)`` of the first declaration of ``function`` (the whole file for ``<global>``), or
|
||||
``None`` when the file does not declare it. Languages without a repo-facts declaration pattern (C, C++, C#, Perl,
|
||||
...) and brace languages whose pattern misses the name use the brace matcher; an unknown language is sniffed
|
||||
(:func:`resolve_language`)."""
|
||||
function = module_function(function)
|
||||
language = resolve_language(lines, language)
|
||||
if function == MODULE_FUNCTION:
|
||||
return (0, len(lines)) if lines else None
|
||||
pattern = DECLARATION.get(language)
|
||||
start = next((i for i, line in enumerate(lines) if _declared_name(pattern, line) == function), None) if pattern else None
|
||||
if start is None:
|
||||
return _brace_bounds(lines, language, function) if language in SLASH_COMMENTS or pattern is None else None
|
||||
span = function_span(lines, language, function)
|
||||
if not span:
|
||||
return None
|
||||
return start, min(start + span, len(lines))
|
||||
|
||||
def product_paths(workspace: Path) -> list[Path]:
|
||||
"""Product source files of the supported languages, sorted; tests, vendored trees, build output, minified
|
||||
bundles and dot-directories are not product (the classification of `model_sinks.py` and the layout facts)."""
|
||||
from .model.layout import is_test_path, is_vendored, layout_facts
|
||||
|
||||
facts = layout_facts()
|
||||
files: list[Path] = []
|
||||
for path in sorted(workspace.rglob("*")):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(workspace)
|
||||
parts = relative.parts
|
||||
if any(part.startswith(".") or part in _EXCLUDED_DIRS for part in parts[:-1]) or parts[-1].startswith("."):
|
||||
continue
|
||||
posix = relative.as_posix()
|
||||
if _NOT_PRODUCT.search(posix) or is_vendored(posix, facts) or is_test_path(posix, facts):
|
||||
continue
|
||||
if detect_language(path) not in LANGUAGES or path.stat().st_size > MAX_FILE_BYTES:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
def declared_functions(lines: Sequence[str], language: str) -> list[str]:
|
||||
"""The function names a file declares, in source order, each once."""
|
||||
pattern = DECLARATION.get(language)
|
||||
if pattern is None:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for line in lines:
|
||||
name = _declared_name(pattern, line)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
def enclosing_at(lines: Sequence[str], index: int, language: str) -> str:
|
||||
"""The nearest declaration at or above line ``index`` (0-based), `<global>` when there is none."""
|
||||
pattern = DECLARATION.get(language)
|
||||
if pattern is None:
|
||||
return MODULE_FUNCTION
|
||||
indent = _indent(lines[index]) if language == "python" else None
|
||||
for back in range(index, -1, -1):
|
||||
name = _declared_name(pattern, lines[back])
|
||||
if not name:
|
||||
continue
|
||||
if indent is None or back == index or _indent(lines[back]) < indent:
|
||||
return name
|
||||
indent = min(indent, _indent(lines[back])) # a sibling or deeper def: keep looking for the one that encloses
|
||||
return MODULE_FUNCTION
|
||||
|
||||
def _indent(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" \t"))
|
||||
|
||||
def call_sites_in(lines: Sequence[str], language: str, names: Iterable[str]) -> list[dict[str, Any]]:
|
||||
"""Calls to any of ``names`` in one file: `{"function", "line", "enclosing"}`, one per name and line, in
|
||||
line order. A declaration line is not a call of the function it declares; comment lines are skipped."""
|
||||
wanted = sorted(set(names), key=lambda n: (-len(n), n))
|
||||
if not wanted:
|
||||
return []
|
||||
token = re.compile(r"(?<![A-Za-z0-9_$])(" + "|".join(re.escape(n) for n in wanted) + r")\s*\(")
|
||||
pattern = DECLARATION.get(language)
|
||||
found: list[dict[str, Any]] = []
|
||||
for index, line in enumerate(lines):
|
||||
if line.lstrip().startswith(_COMMENT_PREFIXES):
|
||||
continue
|
||||
declared = _declared_name(pattern, line) if pattern is not None else ""
|
||||
seen: set[str] = set()
|
||||
for match in token.finditer(line):
|
||||
name = match.group(1)
|
||||
if name == declared or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
found.append({"function": name, "line": index + 1, "enclosing": enclosing_at(lines, index, language)})
|
||||
return found
|
||||
|
||||
def _declared_name(pattern: re.Pattern[str], line: str) -> str:
|
||||
match = pattern.match(line)
|
||||
if not match:
|
||||
return ""
|
||||
name = next((g for g in match.groups() if g), "")
|
||||
return "" if name in KEYWORDS else name
|
||||
|
||||
def function_span(lines: Sequence[str], language: str, function: str) -> int | None:
|
||||
"""Lines of the first declaration of ``function`` up to the next declaration (Python: up to the next line indented
|
||||
no deeper), ``None`` when the file does not declare it. ``<global>`` counts the whole file."""
|
||||
function = module_function(function)
|
||||
if function == MODULE_FUNCTION:
|
||||
return len(lines)
|
||||
pattern = DECLARATION.get(language)
|
||||
if pattern is None:
|
||||
return None
|
||||
start = next((i for i, line in enumerate(lines) if _declared_name(pattern, line) == function), None)
|
||||
if start is None:
|
||||
return None
|
||||
indent = len(lines[start]) - len(lines[start].lstrip(" \t"))
|
||||
for end in range(start + 1, len(lines)):
|
||||
line = lines[end]
|
||||
if language == "python":
|
||||
if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip(" \t")) <= indent:
|
||||
return end - start
|
||||
elif _declared_name(pattern, line):
|
||||
return end - start
|
||||
return len(lines) - start
|
||||
|
||||
|
||||
declared_name = _declared_name
|
||||
Reference in New Issue
Block a user