tina4ruby 3.11.35 → 3.11.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4::ProjectIndex — lightweight "where is what" map of a project.
4
+ # Ported from tina4_python/dev_admin/project_index.py.
5
+ #
6
+ # Storage: .tina4/project_index.json at the project root. Incremental,
7
+ # mtime-based refresh on every read. Per-language extractors (Ruby,
8
+ # ERB/Twig, SQL, JS/TS, Markdown) produce symbol/route/import summaries
9
+ # used by index_search and index_overview.
10
+
11
+ require "json"
12
+ require "digest"
13
+ require "fileutils"
14
+
15
+ module Tina4
16
+ module ProjectIndex
17
+ INDEX_DIRNAME = ".tina4"
18
+ INDEX_FILENAME = "project_index.json"
19
+
20
+ SKIP_DIRS = %w[
21
+ .git .hg .svn node_modules __pycache__ .venv venv .mypy_cache
22
+ .ruff_cache .pytest_cache dist build .tina4 logs .idea .vscode
23
+ vendor coverage tmp .bundle
24
+ ].freeze
25
+
26
+ INDEX_EXT = %w[
27
+ .rb .erb .twig .html .sql .scss .css .js .ts .mjs .md .json .yml
28
+ .yaml .toml .env .rake
29
+ ].freeze
30
+
31
+ MAX_FILE_BYTES = 256 * 1024
32
+
33
+ ROUTE_METHODS = %w[get post put patch delete any any_method secure_get secure_post].freeze
34
+
35
+ class << self
36
+ def project_root
37
+ File.expand_path(Dir.pwd)
38
+ end
39
+
40
+ def index_path
41
+ dir = File.join(project_root, INDEX_DIRNAME)
42
+ FileUtils.mkdir_p(dir)
43
+ File.join(dir, INDEX_FILENAME)
44
+ end
45
+
46
+ # ── Extractors ────────────────────────────────────────────
47
+
48
+ def extract_ruby(text)
49
+ out = { "symbols" => [], "imports" => [], "routes" => [], "docstring" => "" }
50
+ # Pick up first non-blank comment block as pseudo-docstring.
51
+ first_comment = nil
52
+ text.each_line do |ln|
53
+ s = ln.strip
54
+ next if s.empty? || s.start_with?("#!") || s == "# frozen_string_literal: true"
55
+ if s.start_with?("#")
56
+ first_comment = s.sub(/\A#\s*/, "")[0, 200]
57
+ break
58
+ else
59
+ break
60
+ end
61
+ end
62
+ out["docstring"] = first_comment if first_comment
63
+
64
+ text.scan(/^\s*(?:class|module)\s+([A-Z][\w:]*)/) { |m| out["symbols"] << m[0] }
65
+ text.scan(/^\s*def\s+(self\.)?([A-Za-z_][\w!?=]*)/) { |m| out["symbols"] << m[1] }
66
+ text.scan(/^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/) { |m| out["imports"] << m[0] }
67
+ # Tina4.get "/path" OR Tina4::Router.get("/path") OR get "/path" do
68
+ route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s*\(?\s*['"]([^'"]+)['"]/
69
+ text.scan(route_re) do |meth, path|
70
+ next unless ROUTE_METHODS.include?(meth)
71
+ out["routes"] << { "method" => meth.upcase, "path" => path, "handler" => "" }
72
+ end
73
+ out["symbols"].uniq!
74
+ out["imports"].uniq!
75
+ out
76
+ end
77
+
78
+ TWIG_EXTENDS = /\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/.freeze
79
+ TWIG_BLOCK = /\{%\s*block\s+([A-Za-z_][\w-]*)/.freeze
80
+ TWIG_INCLUDE = /\{%\s*include\s+['"]([^'"]+)['"]/.freeze
81
+
82
+ def extract_twig(text)
83
+ {
84
+ "extends" => text.scan(TWIG_EXTENDS).flatten,
85
+ "blocks" => text.scan(TWIG_BLOCK).flatten.uniq.sort,
86
+ "includes" => text.scan(TWIG_INCLUDE).flatten.uniq.sort
87
+ }
88
+ end
89
+
90
+ def extract_erb(text)
91
+ # ERB acts a lot like Twig here — extract partial renders.
92
+ renders = text.scan(/render\s+['"]([^'"]+)['"]/).flatten.uniq
93
+ { "renders" => renders }
94
+ end
95
+
96
+ SQL_CREATE = /create\s+(?:unique\s+)?(table|index|view|trigger|sequence|procedure|function)\s+(?:if\s+not\s+exists\s+)?([A-Za-z_][\w.]*)/i.freeze
97
+ SQL_ALTER = /alter\s+(table|index|view)\s+([A-Za-z_][\w.]*)/i.freeze
98
+
99
+ def extract_sql(text)
100
+ out = { "creates" => [], "alters" => [] }
101
+ text.scan(SQL_CREATE) { |kind, name| out["creates"] << "#{kind.upcase} #{name}" }
102
+ text.scan(SQL_ALTER) { |kind, name| out["alters"] << "#{kind.upcase} #{name}" }
103
+ out
104
+ end
105
+
106
+ JS_EXPORT = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/.freeze
107
+ JS_IMPORT = /^\s*import\s+[^'"]+?['"]([^'"]+)['"]/.freeze
108
+
109
+ def extract_js_ts(text)
110
+ {
111
+ "exports" => text.scan(JS_EXPORT).flatten.uniq.sort,
112
+ "imports" => text.scan(JS_IMPORT).flatten.uniq.sort
113
+ }
114
+ end
115
+
116
+ MD_H1 = /^#\s+(.+)$/.freeze
117
+ MD_H2 = /^##\s+(.+)$/.freeze
118
+
119
+ def extract_md(text)
120
+ {
121
+ "title" => (text[MD_H1, 1] || "").strip,
122
+ "sections" => text.scan(MD_H2).flatten.first(30)
123
+ }
124
+ end
125
+
126
+ def extract_generic(text)
127
+ text.each_line do |line|
128
+ s = line.strip
129
+ next if s.empty? || s.start_with?("<!--")
130
+ return { "first_line" => s[0, 200] }
131
+ end
132
+ {}
133
+ end
134
+
135
+ EXTRACTORS = {
136
+ ".rb" => :extract_ruby,
137
+ ".rake" => :extract_ruby,
138
+ ".erb" => :extract_erb,
139
+ ".twig" => :extract_twig,
140
+ ".html" => :extract_twig,
141
+ ".sql" => :extract_sql,
142
+ ".js" => :extract_js_ts,
143
+ ".mjs" => :extract_js_ts,
144
+ ".ts" => :extract_js_ts,
145
+ ".md" => :extract_md
146
+ }.freeze
147
+
148
+ LANGUAGES = {
149
+ ".rb" => "ruby", ".rake" => "ruby",
150
+ ".erb" => "erb", ".twig" => "twig", ".html" => "html",
151
+ ".sql" => "sql", ".scss" => "scss", ".css" => "css",
152
+ ".js" => "javascript", ".mjs" => "javascript", ".ts" => "typescript",
153
+ ".md" => "markdown", ".json" => "json", ".yml" => "yaml",
154
+ ".yaml" => "yaml", ".toml" => "toml", ".env" => "env"
155
+ }.freeze
156
+
157
+ def language_for(path)
158
+ LANGUAGES[File.extname(path)] || "text"
159
+ end
160
+
161
+ # ── Index core ───────────────────────────────────────────
162
+
163
+ def extract(path)
164
+ stat = File.stat(path)
165
+ rel = path.sub("#{project_root}/", "")
166
+ entry = {
167
+ "path" => rel,
168
+ "size" => stat.size,
169
+ "mtime" => stat.mtime.to_i,
170
+ "language" => language_for(path)
171
+ }
172
+ return entry.merge("skipped" => "too large (#{stat.size} bytes)") if stat.size > MAX_FILE_BYTES
173
+
174
+ text = begin
175
+ File.read(path, encoding: "utf-8", invalid: :replace, undef: :replace)
176
+ rescue StandardError
177
+ return entry
178
+ end
179
+ entry["sha256"] = Digest::SHA256.hexdigest(text)[0, 16]
180
+ ext = File.extname(path)
181
+ extractor = EXTRACTORS[ext]
182
+ begin
183
+ data = extractor ? send(extractor, text) : extract_generic(text)
184
+ entry.merge!(data) if data.is_a?(Hash)
185
+ rescue StandardError => e
186
+ entry["extraction_error"] = e.message[0, 200]
187
+ end
188
+ entry["summary"] = summarise(entry)
189
+ entry
190
+ rescue Errno::ENOENT, Errno::EACCES
191
+ {}
192
+ end
193
+
194
+ def summarise(entry)
195
+ return entry["skipped"] if entry["skipped"]
196
+ return entry["docstring"] if entry["docstring"] && !entry["docstring"].to_s.empty?
197
+ return entry["title"] if entry["title"] && !entry["title"].to_s.empty?
198
+ if entry["routes"] && !entry["routes"].empty?
199
+ r = entry["routes"][0]
200
+ extra = entry["routes"].size > 1 ? " (+#{entry["routes"].size - 1} more)" : ""
201
+ return "#{r["method"]} #{r["path"]}#{extra}"
202
+ end
203
+ return "defines " + entry["symbols"].first(4).join(", ") if entry["symbols"] && !entry["symbols"].empty?
204
+ return "exports " + entry["exports"].first(4).join(", ") if entry["exports"] && !entry["exports"].empty?
205
+ return "schema: " + entry["creates"].first(3).join(", ") if entry["creates"] && !entry["creates"].empty?
206
+ return "template, extends #{entry["extends"][0]}" if entry["extends"] && !entry["extends"].empty?
207
+ return entry["first_line"] if entry["first_line"]
208
+ ""
209
+ end
210
+
211
+ def walk_project
212
+ root = project_root
213
+ found = []
214
+ prefix_len = root.length + 1
215
+ walker = lambda do |dir|
216
+ Dir.each_child(dir) do |name|
217
+ next if name == "." || name == ".."
218
+ # Skip hidden dirs (allow .env as a file below)
219
+ full = File.join(dir, name)
220
+ if File.directory?(full)
221
+ next if SKIP_DIRS.include?(name)
222
+ next if name.start_with?(".")
223
+ walker.call(full)
224
+ elsif File.file?(full)
225
+ if name.start_with?(".")
226
+ next unless name == ".env"
227
+ end
228
+ ext = File.extname(name)
229
+ next unless INDEX_EXT.include?(ext) || name == ".env"
230
+ found << full
231
+ end
232
+ end
233
+ rescue Errno::EACCES, Errno::ENOENT
234
+ # skip
235
+ end
236
+ walker.call(root)
237
+ found
238
+ end
239
+
240
+ def load_raw
241
+ p = index_path
242
+ return { "version" => 1, "files" => {}, "generated_at" => 0 } unless File.exist?(p)
243
+ JSON.parse(File.read(p, encoding: "utf-8"))
244
+ rescue StandardError
245
+ { "version" => 1, "files" => {}, "generated_at" => 0 }
246
+ end
247
+
248
+ def save_raw(data)
249
+ data["generated_at"] = Time.now.to_i
250
+ File.write(index_path, JSON.pretty_generate(data), encoding: "utf-8")
251
+ end
252
+
253
+ def refresh
254
+ data = load_raw
255
+ files = data["files"] || {}
256
+ added = 0
257
+ updated = 0
258
+ seen = {}
259
+ root = project_root
260
+
261
+ walk_project.each do |p|
262
+ rel = p.sub("#{root}/", "")
263
+ seen[rel] = true
264
+ begin
265
+ mtime = File.mtime(p).to_i
266
+ rescue Errno::ENOENT
267
+ next
268
+ end
269
+ existing = files[rel]
270
+ if existing && existing["mtime"] == mtime
271
+ next
272
+ end
273
+ files[rel] = extract(p)
274
+ if existing
275
+ updated += 1
276
+ else
277
+ added += 1
278
+ end
279
+ end
280
+
281
+ removed_paths = files.keys - seen.keys
282
+ removed_paths.each { |k| files.delete(k) }
283
+ data["files"] = files
284
+ save_raw(data)
285
+ {
286
+ "added" => added,
287
+ "updated" => updated,
288
+ "removed" => removed_paths.size,
289
+ "total" => files.size,
290
+ "path" => index_path.sub("#{root}/", "")
291
+ }
292
+ end
293
+
294
+ def search(query, limit = 20)
295
+ refresh
296
+ data = load_raw
297
+ q = query.to_s.downcase.strip
298
+ return [] if q.empty?
299
+ hits = []
300
+ data["files"].each do |rel, entry|
301
+ score = 0
302
+ score += 10 if rel.downcase.include?(q)
303
+ (entry["symbols"] || []).each do |s|
304
+ sl = s.downcase
305
+ if sl == q
306
+ score += 8
307
+ elsif sl.include?(q)
308
+ score += 4
309
+ end
310
+ end
311
+ (entry["routes"] || []).each do |r|
312
+ combined = "#{r["path"]} #{r["handler"]}".downcase
313
+ score += 5 if combined.include?(q)
314
+ end
315
+ score += 3 if (entry["summary"] || "").downcase.include?(q)
316
+ (entry["imports"] || []).each { |imp| score += 1 if imp.downcase.include?(q) }
317
+ if score.positive?
318
+ hits << [score, {
319
+ "path" => rel,
320
+ "summary" => entry["summary"] || "",
321
+ "score" => score,
322
+ "language" => entry["language"] || ""
323
+ }]
324
+ end
325
+ end
326
+ hits.sort_by! { |h| -h[0] }
327
+ hits.first([1, limit].max).map { |_, info| info }
328
+ end
329
+
330
+ def file_entry(rel_path)
331
+ refresh
332
+ data = load_raw
333
+ entry = data["files"][rel_path]
334
+ entry || { "error" => "Not in index: #{rel_path}" }
335
+ end
336
+
337
+ def overview
338
+ refresh
339
+ data = load_raw
340
+ files = data["files"]
341
+ langs = Hash.new(0)
342
+ route_count = 0
343
+ model_count = 0
344
+ files.each_value do |e|
345
+ langs[e["language"] || "other"] += 1
346
+ route_count += (e["routes"] || []).size
347
+ path = e["path"].to_s
348
+ if (path.start_with?("src/orm/") || path.start_with?("orm/") || path.start_with?("app/models/")) && (e["symbols"] && !e["symbols"].empty?)
349
+ model_count += 1
350
+ end
351
+ end
352
+ recent = files.values.map do |e|
353
+ { "path" => e["path"], "summary" => e["summary"] || "", "mtime" => e["mtime"] || 0 }
354
+ end.sort_by { |e| -(e["mtime"] || 0) }.first(10)
355
+ {
356
+ "total_files" => files.size,
357
+ "by_language" => langs,
358
+ "routes_declared" => route_count,
359
+ "orm_models" => model_count,
360
+ "recently_changed" => recent,
361
+ "index_generated_at" => data["generated_at"] || 0
362
+ }
363
+ end
364
+ end
365
+ end
366
+ end