mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
f40db4b9cc
Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.
Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.
Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.
Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.
Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.
Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.
Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
8.0 KiB
Rust
241 lines
8.0 KiB
Rust
//! Shared utilities for the TS/JS walker: compiled regexes, UTF-16 position
|
|
//! conversion, generated-file detection, and small text helpers — each
|
|
//! mirroring a specific helper in src/extraction/tree-sitter.ts (noted inline).
|
|
|
|
use regex::Regex;
|
|
use std::sync::OnceLock;
|
|
|
|
macro_rules! re {
|
|
($name:ident, $pat:expr) => {
|
|
pub fn $name() -> &'static Regex {
|
|
static RE: OnceLock<Regex> = OnceLock::new();
|
|
RE.get_or_init(|| Regex::new($pat).expect(concat!("regex ", stringify!($name))))
|
|
}
|
|
};
|
|
}
|
|
|
|
// RTK_HOOK_NAME_RE (tree-sitter.ts)
|
|
re!(rtk_hook_name, r"^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$");
|
|
// reactComponentHoc's styled test
|
|
re!(styled_callee, r"^styled\b");
|
|
// PascalCase component gate (#841)
|
|
re!(pascal_case, r"^[A-Z]");
|
|
// extractCall parenthesized-conversion normalization
|
|
re!(paren_conversion, r"^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$");
|
|
// flushFnRefCandidates SIMPLE_NAME
|
|
re!(simple_name, r"^[A-Za-z_$][A-Za-z0-9_$]*$");
|
|
// flushFnRefCandidates QUALIFIED_IMPORT
|
|
re!(qualified_import, r"^[A-Za-z_$][A-Za-z0-9_$.\\]*[.\\]([A-Za-z_$][A-Za-z0-9_$]*)$");
|
|
// captureFnRefCandidates rhs param-storage skip — trailing identifier of LHS
|
|
re!(lhs_last_name, r"([A-Za-z_$][A-Za-z0-9_$]*)\s*$");
|
|
// extractTsTupleContractNames identifier test
|
|
re!(ident_dollar, r"^[A-Za-z_$][A-Za-z0-9_$]*$");
|
|
// looksLikeVueStoreFile signal (VUE_STORE_FILE_SIGNAL)
|
|
re!(
|
|
vue_store_signal,
|
|
r"\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b"
|
|
);
|
|
// value-ref target-name distinctiveness: /[A-Z_]/
|
|
re!(has_upper_or_underscore, r"[A-Z_]");
|
|
|
|
/// isGeneratedFile (src/extraction/generated-detection.ts) — full pattern list
|
|
/// ported so future language walkers share it.
|
|
pub fn is_generated_file(file_path: &str) -> bool {
|
|
static RES: OnceLock<Vec<Regex>> = OnceLock::new();
|
|
let patterns = RES.get_or_init(|| {
|
|
[
|
|
r"\.pb\.go$",
|
|
r"\.pulsar\.go$",
|
|
r"_grpc\.pb\.go$",
|
|
r"_mock\.go$",
|
|
r"_mocks\.go$",
|
|
r"^mock_[^/]+\.go$",
|
|
r"\.generated\.[jt]sx?$",
|
|
r"\.gen\.[jt]sx?$",
|
|
r"\.pb\.[jt]s$",
|
|
r"_pb\.[jt]s$",
|
|
r"_grpc_pb\.[jt]s$",
|
|
r"[.-]min\.m?js$",
|
|
r"_pb2(_grpc)?\.py$",
|
|
r"_pb2\.pyi$",
|
|
r"\.pb\.(cc|h)$",
|
|
r"\.g\.cs$",
|
|
r"Grpc\.cs$",
|
|
r"OuterClass\.java$",
|
|
r"Grpc\.java$",
|
|
r"\.pb\.swift$",
|
|
r"\.g\.dart$",
|
|
r"\.freezed\.dart$",
|
|
r"\.pb\.dart$",
|
|
r"\.pbgrpc\.dart$",
|
|
r"\.chopper\.dart$",
|
|
r"\.generated\.rs$",
|
|
]
|
|
.iter()
|
|
.map(|p| Regex::new(p).expect("generated pattern"))
|
|
.collect()
|
|
});
|
|
patterns.iter().any(|p| p.is_match(file_path))
|
|
}
|
|
|
|
// isMinifiedContent's WEBPACK_RUNTIME (`\b` ASCII, as in JS).
|
|
re!(webpack_runtime, r"(?-u:\b)function __webpack_require__\s*\(");
|
|
|
|
/// isMinifiedContent (src/extraction/generated-detection.ts): a `.js` / `.mjs`
|
|
/// / `.cjs` file of at least 4000 UTF-16 units whose text sits mostly on lines
|
|
/// of 1000 units or more, dense with `;{}(),` — or that carries a webpack
|
|
/// runtime. Lengths are counted in UTF-16 units, as the JS string ops count
|
|
/// them, so both extractors agree on every file.
|
|
pub fn is_minified_content(file_path: &str, content: &str) -> bool {
|
|
const LINE: usize = 1000;
|
|
let lower = file_path.to_ascii_lowercase();
|
|
if !(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs")) {
|
|
return false;
|
|
}
|
|
// UTF-16 units never outnumber UTF-8 bytes: a short file is out at once.
|
|
if content.len() < 4 * LINE {
|
|
return false;
|
|
}
|
|
let total = content.encode_utf16().count();
|
|
if total < 4 * LINE {
|
|
return false;
|
|
}
|
|
if webpack_runtime().is_match(content) {
|
|
return true;
|
|
}
|
|
let (mut long, mut punctuation) = (0usize, 0usize);
|
|
let (mut line_len, mut line_punct) = (0usize, 0usize);
|
|
for unit in content.encode_utf16() {
|
|
if unit == 10 {
|
|
if line_len >= LINE {
|
|
long += line_len;
|
|
punctuation += line_punct;
|
|
}
|
|
line_len = 0;
|
|
line_punct = 0;
|
|
continue;
|
|
}
|
|
line_len += 1;
|
|
// ; { } ( ) ,
|
|
if matches!(unit, 59 | 123 | 125 | 40 | 41 | 44) {
|
|
line_punct += 1;
|
|
}
|
|
}
|
|
if line_len >= LINE {
|
|
long += line_len;
|
|
punctuation += line_punct;
|
|
}
|
|
long as f64 >= total as f64 * 0.5 && punctuation as f64 >= long as f64 * 0.03
|
|
}
|
|
|
|
/// Byte offsets of each line start, for UTF-16 column conversion.
|
|
pub fn line_starts(src: &str) -> Vec<usize> {
|
|
let mut out = vec![0usize];
|
|
for (i, b) in src.bytes().enumerate() {
|
|
if b == b'\n' {
|
|
out.push(i + 1);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// UTF-16 code units in `s` — what web-tree-sitter (and JS string ops)
|
|
/// count, so kernel-emitted columns are byte-identical to the wasm path's.
|
|
pub fn utf16_len(s: &str) -> usize {
|
|
s.chars().map(|c| c.len_utf16()).sum()
|
|
}
|
|
|
|
/// Column (UTF-16 units) of `byte_pos` on line `row`, given `line_starts`.
|
|
pub fn col16(src: &str, starts: &[usize], row: usize, byte_pos: usize) -> u32 {
|
|
let ls = starts.get(row).copied().unwrap_or(0);
|
|
if byte_pos <= ls {
|
|
return 0;
|
|
}
|
|
utf16_len(&src[ls..byte_pos]) as u32
|
|
}
|
|
|
|
/// JS `String.prototype.slice(0, n)` in UTF-16 units, without splitting a
|
|
/// surrogate pair (when the cut would split one, we stop one code unit short —
|
|
/// a lone surrogate isn't representable in Rust and never round-trips through
|
|
/// SQLite anyway). Returns (sliced, was_truncated_at_or_beyond_n).
|
|
pub fn slice_utf16(s: &str, n: usize) -> (String, bool) {
|
|
let mut used = 0usize;
|
|
let mut out = String::new();
|
|
for c in s.chars() {
|
|
let w = c.len_utf16();
|
|
if used + w > n {
|
|
return (out, true);
|
|
}
|
|
used += w;
|
|
out.push(c);
|
|
if used == n {
|
|
// Exactly at the limit: truncated iff any source remains.
|
|
let truncated = out.len() < s.len();
|
|
return (out, truncated);
|
|
}
|
|
}
|
|
(out, false)
|
|
}
|
|
|
|
/// objectKeyName (tree-sitter.ts): strip ONE leading and ONE trailing quote
|
|
/// character (`'`, `"`, or backtick).
|
|
pub fn object_key_name(s: &str) -> String {
|
|
let mut out = s;
|
|
if let Some(first) = out.chars().next() {
|
|
if first == '\'' || first == '"' || first == '`' {
|
|
out = &out[first.len_utf8()..];
|
|
}
|
|
}
|
|
if let Some(last) = out.chars().last() {
|
|
if last == '\'' || last == '"' || last == '`' {
|
|
out = &out[..out.len() - last.len_utf8()];
|
|
}
|
|
}
|
|
out.to_string()
|
|
}
|
|
|
|
/// The `= <first 100 UTF-16 units>[...]` initializer signature used by
|
|
/// extractVariable (its `.length >= 100` check fires exactly when the slice
|
|
/// hit the cap).
|
|
pub fn init_signature(value_text: &str) -> String {
|
|
let (sliced, _) = slice_utf16(value_text, 100);
|
|
if utf16_len(&sliced) >= 100 {
|
|
format!("= {sliced}...")
|
|
} else {
|
|
format!("= {sliced}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn utf16_cols() {
|
|
let src = "aé😀b";
|
|
// 'a'=1, 'é'=1, '😀'=2 utf16 units; bytes: a=1, é=2, 😀=4
|
|
assert_eq!(utf16_len(src), 5);
|
|
let starts = line_starts(src);
|
|
assert_eq!(col16(src, &starts, 0, 1), 1); // after 'a'
|
|
assert_eq!(col16(src, &starts, 0, 3), 2); // after 'é'
|
|
assert_eq!(col16(src, &starts, 0, 7), 4); // after '😀'
|
|
}
|
|
|
|
#[test]
|
|
fn init_sig_short_and_long() {
|
|
assert_eq!(init_signature("[1, 2]"), "= [1, 2]");
|
|
let long = "x".repeat(150);
|
|
let sig = init_signature(&long);
|
|
assert!(sig.starts_with("= "));
|
|
assert!(sig.ends_with("..."));
|
|
assert_eq!(utf16_len(&sig[2..sig.len() - 3]), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_patterns() {
|
|
assert!(is_generated_file("src/api.generated.ts"));
|
|
assert!(is_generated_file("vendor/jquery.min.js"));
|
|
assert!(!is_generated_file("src/app.ts"));
|
|
}
|
|
}
|