Skip to content

Commit 61344fe

Browse files
committed
feat: tsconfig/jsconfig discovery, tolerant JSONC parser, paths alias resolution (issue-3, PR 1/3)
1 parent 5d6ed9b commit 61344fe

2 files changed

Lines changed: 342 additions & 2 deletions

File tree

src/repokg/deps.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import ast
8+
import json
89
import os
910
import re
1011
from collections import Counter
@@ -16,9 +17,12 @@
1617
GO_QUOTED_RE = re.compile(r'"([^"]+)"')
1718
GO_MODULE_RE = re.compile(r"^module\s+(\S+)", re.M)
1819
JS_IMPORT_RE = re.compile(
19-
r"""(?:from\s+|require\(\s*|import\(\s*|^\s*import\s+)['"](\.{1,2}/[^'"]+)['"]""",
20+
r"""(?:from\s+|require\(\s*|import\(\s*|^\s*import\s+)['"]([^'"]+)['"]""",
2021
re.M)
2122
JS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs")
23+
# tsconfig wins over jsconfig when a dir carries both (jsconfig is the
24+
# JS-only subset of the same format).
25+
JS_CONFIG_FILES = ("tsconfig.json", "jsconfig.json")
2226
# [package] section of a Cargo.toml, up to the next table header.
2327
CARGO_PACKAGE_RE = re.compile(r"^\[package\]\s*$(.*?)(?=^\[|\Z)", re.M | re.S)
2428
CARGO_NAME_RE = re.compile(r'^\s*name\s*=\s*"([^"]+)"', re.M)
@@ -162,16 +166,154 @@ def _py_edge(rel, module, level, pkg_map, dirs, counter):
162166
# -- JS / TS -----------------------------------------------------------------
163167

164168
def _js_edges(repo, tree, dirs, counter):
169+
"""Relative imports resolve directly; non-relative specifiers go through
170+
the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl` (bare
171+
third-party imports match nothing there and drop out)."""
172+
configs = _js_configs(repo, tree)
165173
for rel, files in tree.items():
174+
cfg = _owning_root(rel, configs) if configs else None
166175
for f in files:
167176
if not f.endswith(JS_EXTS) or f.endswith(".d.ts"):
168177
continue
169178
for imp in JS_IMPORT_RE.findall(_read(repo, rel, f)):
170-
target = _existing_dir(_norm(os.path.join(rel, imp)), dirs)
179+
if imp.startswith("."):
180+
target = _existing_dir(_norm(os.path.join(rel, imp)), dirs)
181+
elif cfg is not None and not imp.startswith("/"):
182+
target = _js_alias_resolve(imp, configs[cfg], dirs)
183+
else:
184+
target = None
171185
if target is not None and target != rel:
172186
counter[(rel, target, "JS/TS")] += 1
173187

174188

189+
def _js_configs(repo, tree):
190+
"""{config dir: (paths_base, patterns, bare_base)} from tsconfig/jsconfig.
191+
192+
paths_base: dir that `paths` values resolve against — baseUrl when set,
193+
else the config's own dir (TS 4.4 semantics). patterns: (pattern, values)
194+
sorted most-specific-first (longest literal prefix before `*`), matching
195+
tsconfig's own tie-break. bare_base: baseUrl dir when explicitly set —
196+
bare specifiers additionally resolve against it, after `paths`.
197+
Configs defining neither baseUrl nor paths are skipped so an
198+
extends-only leaf config does not shadow the root's aliases
199+
(`extends` chains themselves are not followed).
200+
"""
201+
configs = {}
202+
for rel, files in tree.items():
203+
name = next((c for c in JS_CONFIG_FILES if c in files), None)
204+
if name is None:
205+
continue
206+
data = _jsonc_loads(_read(repo, rel, name))
207+
opts = data.get("compilerOptions") if isinstance(data, dict) else None
208+
if not isinstance(opts, dict):
209+
continue
210+
base = opts.get("baseUrl")
211+
bare_base = _norm(os.path.join(rel, base)) if isinstance(base, str) else None
212+
patterns = []
213+
paths = opts.get("paths")
214+
if isinstance(paths, dict):
215+
for pat, vals in paths.items():
216+
if not isinstance(pat, str) or not isinstance(vals, list):
217+
continue
218+
vals = [v for v in vals if isinstance(v, str)]
219+
if vals:
220+
patterns.append((pat, vals))
221+
patterns.sort( # exact patterns beat wildcards; longer prefix wins
222+
key=lambda pv: ("*" in pv[0], -pv[0].find("*"), pv[0]))
223+
if bare_base is None and not patterns:
224+
continue
225+
configs[rel] = (bare_base if bare_base is not None else rel,
226+
patterns, bare_base)
227+
return configs
228+
229+
230+
def _js_alias_resolve(imp, cfg, dirs):
231+
"""Resolve a non-relative specifier through `paths` patterns (first
232+
existing substitution wins), then baseUrl-relative lookup; None when
233+
nothing grounds in the repo tree."""
234+
paths_base, patterns, bare_base = cfg
235+
for pat, vals in patterns:
236+
star = pat.find("*")
237+
if star == -1:
238+
if imp != pat:
239+
continue
240+
stem = ""
241+
else:
242+
pre, suf = pat[:star], pat[star + 1:]
243+
if not (len(imp) >= len(pre) + len(suf)
244+
and imp.startswith(pre) and imp.endswith(suf)):
245+
continue
246+
stem = imp[len(pre):len(imp) - len(suf)]
247+
for val in vals:
248+
target = _existing_dir(
249+
_norm(os.path.join(paths_base, val.replace("*", stem, 1))),
250+
dirs)
251+
if target is not None:
252+
return target
253+
if bare_base is not None:
254+
return _existing_dir(_norm(os.path.join(bare_base, imp)), dirs)
255+
return None
256+
257+
258+
def _jsonc_loads(text):
259+
"""json.loads for the JSONC dialect tsconfig uses: // and /* */ comments
260+
and trailing commas are stripped (string-aware, so comment-lookalikes
261+
inside string values survive). Returns None when still not valid JSON."""
262+
out, i, n = [], 0, len(text)
263+
while i < n: # pass 1: strip comments
264+
c = text[i]
265+
if c == '"':
266+
j = _jsonc_string_end(text, i)
267+
out.append(text[i:j])
268+
i = j
269+
elif text[i:i + 2] == "//":
270+
while i < n and text[i] != "\n":
271+
i += 1
272+
elif text[i:i + 2] == "/*":
273+
end = text.find("*/", i + 2)
274+
i = n if end == -1 else end + 2
275+
else:
276+
out.append(c)
277+
i += 1
278+
text = "".join(out)
279+
out, i, n = [], 0, len(text)
280+
while i < n: # pass 2: drop trailing commas
281+
c = text[i]
282+
if c == '"':
283+
j = _jsonc_string_end(text, i)
284+
out.append(text[i:j])
285+
i = j
286+
elif c == ",":
287+
j = i + 1
288+
while j < n and text[j] in " \t\r\n":
289+
j += 1
290+
if j < n and text[j] in "}]":
291+
i += 1 # trailing comma: skip it
292+
else:
293+
out.append(c)
294+
i += 1
295+
else:
296+
out.append(c)
297+
i += 1
298+
try:
299+
return json.loads("".join(out))
300+
except ValueError:
301+
return None
302+
303+
304+
def _jsonc_string_end(text, i):
305+
"""Index just past the string literal opening at text[i]."""
306+
j, n = i + 1, len(text)
307+
while j < n:
308+
if text[j] == "\\":
309+
j += 2
310+
elif text[j] == '"':
311+
return j + 1
312+
else:
313+
j += 1
314+
return n
315+
316+
175317
# -- Rust --------------------------------------------------------------------
176318

177319
def _rust_edges(repo, tree, counter):

tests/test_deps_js.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import os
2+
import tempfile
3+
import unittest
4+
5+
from repokg.code import walk
6+
from repokg.deps import _js_configs, _jsonc_loads, collect
7+
8+
9+
def write(root, rel, text):
10+
path = os.path.join(root, rel)
11+
os.makedirs(os.path.dirname(path), exist_ok=True)
12+
with open(path, "w", encoding="utf-8") as f:
13+
f.write(text)
14+
15+
16+
class TestJsoncLoads(unittest.TestCase):
17+
def test_plain_json(self):
18+
self.assertEqual(_jsonc_loads('{"a": [1, 2]}'), {"a": [1, 2]})
19+
20+
def test_comments_stripped(self):
21+
self.assertEqual(_jsonc_loads(
22+
'{\n'
23+
' // line comment\n'
24+
' "a": 1, /* block\n'
25+
' comment */ "b": 2\n'
26+
'}'), {"a": 1, "b": 2})
27+
28+
def test_trailing_commas_stripped(self):
29+
self.assertEqual(_jsonc_loads('{"a": [1, 2, ], "b": {"c": 3,},}'),
30+
{"a": [1, 2], "b": {"c": 3}})
31+
32+
def test_comment_lookalikes_inside_strings_survive(self):
33+
self.assertEqual(_jsonc_loads(
34+
'{"url": "https://x.dev", "glob": "src/**/*", "csv": "a,}"}'),
35+
{"url": "https://x.dev", "glob": "src/**/*", "csv": "a,}"})
36+
37+
def test_escaped_quote_inside_string(self):
38+
self.assertEqual(_jsonc_loads('{"a": "say \\"hi\\" // ok",}'),
39+
{"a": 'say "hi" // ok'})
40+
41+
def test_invalid_returns_none(self):
42+
self.assertIsNone(_jsonc_loads("{not json"))
43+
self.assertIsNone(_jsonc_loads(""))
44+
45+
46+
class TestJsConfigs(unittest.TestCase):
47+
def setUp(self):
48+
self.tmp = tempfile.TemporaryDirectory()
49+
self.repo = self.tmp.name
50+
51+
def tearDown(self):
52+
self.tmp.cleanup()
53+
54+
def tree(self):
55+
return dict(walk(self.repo))
56+
57+
def test_baseurl_and_paths(self):
58+
write(self.repo, "tsconfig.json",
59+
'{"compilerOptions": {"baseUrl": "./src",'
60+
' "paths": {"@/*": ["./*"]}}}')
61+
write(self.repo, "src/app.ts", "")
62+
configs = _js_configs(self.repo, self.tree())
63+
self.assertEqual(configs, {"": ("src", [("@/*", ["./*"])], "src")})
64+
65+
def test_paths_without_baseurl_resolve_from_config_dir(self):
66+
write(self.repo, "web/tsconfig.json",
67+
'{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}')
68+
configs = _js_configs(self.repo, self.tree())
69+
self.assertEqual(configs["web"], ("web", [("@/*", ["./src/*"])], None))
70+
71+
def test_jsconfig_supported_tsconfig_preferred(self):
72+
write(self.repo, "a/jsconfig.json",
73+
'{"compilerOptions": {"baseUrl": "."}}')
74+
write(self.repo, "b/jsconfig.json",
75+
'{"compilerOptions": {"baseUrl": "./x"}}')
76+
write(self.repo, "b/tsconfig.json",
77+
'{"compilerOptions": {"baseUrl": "./y"}}')
78+
configs = _js_configs(self.repo, self.tree())
79+
self.assertEqual(configs["a"], ("a", [], "a"))
80+
self.assertEqual(configs["b"], ("b/y", [], "b/y"))
81+
82+
def test_config_without_aliases_ignored(self):
83+
write(self.repo, "tsconfig.json",
84+
'{"extends": "./tsconfig.base.json",'
85+
' "compilerOptions": {"strict": true}}')
86+
self.assertEqual(_js_configs(self.repo, self.tree()), {})
87+
88+
def test_most_specific_pattern_first(self):
89+
write(self.repo, "tsconfig.json",
90+
'{"compilerOptions": {"paths": {'
91+
'"@/*": ["./src/*"], "@/lib/*": ["./lib/*"], "@cfg": ["./cfg.ts"]}}}')
92+
_, patterns, _ = _js_configs(self.repo, self.tree())[""]
93+
self.assertEqual([p for p, _ in patterns], ["@cfg", "@/lib/*", "@/*"])
94+
95+
96+
class TestJsAliasEdges(unittest.TestCase):
97+
def setUp(self):
98+
self.tmp = tempfile.TemporaryDirectory()
99+
self.repo = self.tmp.name
100+
101+
def tearDown(self):
102+
self.tmp.cleanup()
103+
104+
def edges(self):
105+
return {(e["from"], e["to"]): e["count"]
106+
for e in collect(self.repo) if e["lang"] == "JS/TS"}
107+
108+
def test_alias_import_matches_equivalent_relative_import(self):
109+
write(self.repo, "tsconfig.json",
110+
'{"compilerOptions": {"baseUrl": ".",'
111+
' "paths": {"@/*": ["./src/*"]}}}')
112+
write(self.repo, "src/components/button.tsx", "export const B = 1\n")
113+
write(self.repo, "pages/index.tsx",
114+
"import { B } from '@/components/button'\n"
115+
"import { B as B2 } from '../src/components/button'\n")
116+
self.assertEqual(self.edges(),
117+
{("pages", "src/components"): 2})
118+
119+
def test_bare_third_party_imports_drop(self):
120+
write(self.repo, "tsconfig.json",
121+
'{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}')
122+
write(self.repo, "src/util.ts", "export const u = 1\n")
123+
write(self.repo, "app/main.ts",
124+
"import React from 'react'\n"
125+
"import fs from 'node:fs'\n"
126+
"const x = require('lodash/get')\n")
127+
self.assertEqual(self.edges(), {})
128+
129+
def test_exact_alias_to_file_resolves_to_parent_dir(self):
130+
write(self.repo, "tsconfig.json",
131+
'{"compilerOptions": {"paths": {"@config": ["./src/config.ts"]}}}')
132+
write(self.repo, "src/config.ts", "export const c = 1\n")
133+
write(self.repo, "app/main.ts", "import { c } from '@config'\n")
134+
self.assertEqual(self.edges(), {("app", "src"): 1})
135+
136+
def test_baseurl_bare_import(self):
137+
write(self.repo, "jsconfig.json",
138+
'{"compilerOptions": {"baseUrl": "."}}')
139+
write(self.repo, "lib/util.js", "module.exports = {}\n")
140+
write(self.repo, "app/main.js", "const u = require('lib/util')\n")
141+
self.assertEqual(self.edges(), {("app", "lib"): 1})
142+
143+
def test_nearest_config_wins_and_shadows_root(self):
144+
write(self.repo, "tsconfig.json",
145+
'{"compilerOptions": {"paths": {"~/*": ["./shared/*"]}}}')
146+
write(self.repo, "shared/log.ts", "export const l = 1\n")
147+
write(self.repo, "packages/app/tsconfig.json",
148+
'{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}')
149+
write(self.repo, "packages/app/src/db.ts", "export const d = 1\n")
150+
write(self.repo, "packages/app/main.ts",
151+
"import { d } from '@/db'\n" # nearest config's alias
152+
"import { l } from '~/log'\n") # root alias: shadowed, drops
153+
write(self.repo, "tools/gen.ts",
154+
"import { l } from '~/log'\n") # root config applies here
155+
self.assertEqual(self.edges(), {
156+
("packages/app", "packages/app/src"): 1,
157+
("tools", "shared"): 1,
158+
})
159+
160+
def test_unresolved_alias_target_drops(self):
161+
write(self.repo, "tsconfig.json",
162+
'{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}')
163+
write(self.repo, "src/a.ts", "export const a = 1\n")
164+
write(self.repo, "app/main.ts", "import { g } from '@/gone/deep/x'\n")
165+
self.assertEqual(self.edges(), {})
166+
167+
def test_first_existing_paths_value_wins(self):
168+
write(self.repo, "tsconfig.json",
169+
'{"compilerOptions": {"paths": {"@/*": ["./missing/*", "./src/*"]}}}')
170+
write(self.repo, "src/a.ts", "export const a = 1\n")
171+
write(self.repo, "app/main.ts", "import { a } from '@/a'\n")
172+
self.assertEqual(self.edges(), {("app", "src"): 1})
173+
174+
def test_relative_imports_unchanged_without_configs(self):
175+
write(self.repo, "src/a.ts", "export const a = 1\n")
176+
write(self.repo, "app/main.ts",
177+
"import { a } from '../src/a'\n"
178+
"import missing from '@/nope'\n")
179+
self.assertEqual(self.edges(), {("app", "src"): 1})
180+
181+
def test_jsonc_config_with_comments_and_trailing_commas(self):
182+
write(self.repo, "tsconfig.json",
183+
'{\n'
184+
' // aliases\n'
185+
' "compilerOptions": {\n'
186+
' "baseUrl": ".", /* root */\n'
187+
' "paths": {\n'
188+
' "@/*": ["./src/*"],\n'
189+
' },\n'
190+
' },\n'
191+
'}\n')
192+
write(self.repo, "src/a.ts", "export const a = 1\n")
193+
write(self.repo, "app/main.ts", "import { a } from '@/a'\n")
194+
self.assertEqual(self.edges(), {("app", "src"): 1})
195+
196+
197+
if __name__ == "__main__":
198+
unittest.main()

0 commit comments

Comments
 (0)