logica_compiler 0.1.0

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +138 -0
  4. data/Rakefile +76 -0
  5. data/exe/logica_compiler +7 -0
  6. data/lib/logica_compiler/active_record/runner.rb +89 -0
  7. data/lib/logica_compiler/cli.rb +76 -0
  8. data/lib/logica_compiler/commands/base.rb +45 -0
  9. data/lib/logica_compiler/commands/clean.rb +25 -0
  10. data/lib/logica_compiler/commands/compile.rb +57 -0
  11. data/lib/logica_compiler/commands/install.rb +21 -0
  12. data/lib/logica_compiler/commands/logica.rb +61 -0
  13. data/lib/logica_compiler/commands/version.rb +14 -0
  14. data/lib/logica_compiler/commands/watch.rb +23 -0
  15. data/lib/logica_compiler/compiler.rb +304 -0
  16. data/lib/logica_compiler/config.rb +99 -0
  17. data/lib/logica_compiler/deps/clock.rb +10 -0
  18. data/lib/logica_compiler/deps/env.rb +35 -0
  19. data/lib/logica_compiler/deps/shell.rb +31 -0
  20. data/lib/logica_compiler/deps/sleeper.rb +11 -0
  21. data/lib/logica_compiler/errors.rb +22 -0
  22. data/lib/logica_compiler/installer.rb +204 -0
  23. data/lib/logica_compiler/manifest.rb +31 -0
  24. data/lib/logica_compiler/railtie.rb +11 -0
  25. data/lib/logica_compiler/registry.rb +97 -0
  26. data/lib/logica_compiler/sql_dialect/postgres.rb +13 -0
  27. data/lib/logica_compiler/sql_dialect/sqlite.rb +13 -0
  28. data/lib/logica_compiler/sql_safety.rb +176 -0
  29. data/lib/logica_compiler/tasks/logica_compiler.rake +20 -0
  30. data/lib/logica_compiler/templates/bin_logica.erb +6 -0
  31. data/lib/logica_compiler/templates/config.yml.erb +7 -0
  32. data/lib/logica_compiler/templates/gitignore_block.erb +3 -0
  33. data/lib/logica_compiler/templates/hello_world.l.erb +9 -0
  34. data/lib/logica_compiler/templates/initializer.rb.erb +28 -0
  35. data/lib/logica_compiler/templates/requirements.txt.erb +1 -0
  36. data/lib/logica_compiler/util.rb +51 -0
  37. data/lib/logica_compiler/version.rb +5 -0
  38. data/lib/logica_compiler/watcher.rb +83 -0
  39. data/lib/logica_compiler.rb +13 -0
  40. data/sig/logica_compiler.rbs +4 -0
  41. metadata +177 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a21c97a7ed5735e73959fa9a4e6e04d3a517c76f65c2d183d76f4b8aa3d02c57
4
+ data.tar.gz: eec80700ad9eac6739a4ee2a3789cb014d88492aa3c9bfefd2a887239ca87a4c
5
+ SHA512:
6
+ metadata.gz: 4c3ea01fcf9492835cd2cea7fa91cf69a0844a38079e8b624f2ea97a932cca6f6587fd3dfe7f276f5562a83bb4dcdc4f748a40dd74ac350a8ec690f787731ba0
7
+ data.tar.gz: 725002578ad009f78b8b20b7eae0208efec117aa449972b1cba226c4baa9cfac94d9c03c0d8d47c197a2f2a578bd6a5e80c8a966bfedf1319a2dafad948d5752
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 jasl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # logica_compiler
2
+
3
+ `logica_compiler` integrates [Logica](https://github.com/evgskv/logica) as an **offline SQL compiler**:
4
+
5
+ - You author Logica programs (`.l`)
6
+ - During build/dev, they are compiled into **digested** `.sql` files + a `manifest.json`
7
+ - At runtime, your Rails app **only reads the manifest + SQL** and executes via ActiveRecord
8
+
9
+ This keeps your web process fast (no runtime compilation) and avoids running a separate Python query service.
10
+
11
+ ## What this gem provides
12
+
13
+ - **Compiler**: `.l` → digested `.sql` + `.meta.json` + `manifest.json`
14
+ - **Safety checks**: only allow `SELECT/WITH`, disallow multi-statement `;`, disallow row locks (`FOR UPDATE`, …) and DDL/DML keywords
15
+ - **CLI (Thor)**: `install / compile / clean / watch / version`
16
+ - **Optional ActiveRecord runner**: execute compiled SQL with binds, dialect-aware placeholders
17
+ - Postgres: `$1, $2, ...` + `SET LOCAL statement_timeout`
18
+ - SQLite: `?` placeholders + timeout no-op
19
+ - **Rails install task** (via Railtie): `rake logica_compiler:install` generates thin integration files into your app
20
+
21
+ ## Installation
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem "logica_compiler", github: "jasl/logica_compiler.rb", require: "logica_compiler"
26
+ ```
27
+
28
+ Then:
29
+
30
+ ```bash
31
+ bundle install
32
+ bundle exec rake logica_compiler:install
33
+ ```
34
+
35
+ This generates (idempotently):
36
+
37
+ - `logica/config.yml` (with a demo query)
38
+ - `logica/requirements.txt` (pins `logica==1.3.1415926535897`)
39
+ - `logica/programs/hello_world.l` (demo program)
40
+ - `logica/programs/.keep`, `logica/compiled/.keep`
41
+ - `.gitignore` rules to ignore `logica/compiled/*` (but keep `.keep`)
42
+ - `bin/logica` (thin wrapper around the gem CLI)
43
+ - `config/initializers/logica_compiler.rb` (injects registry + runner into `Rails.application.config.x.logica`)
44
+
45
+ ## CLI usage
46
+
47
+ From the Rails app root:
48
+
49
+ ```bash
50
+ # ensure Python Logica is available (uses .venv/bin/logica if present, otherwise `logica` on PATH, otherwise installs into tmp/logica_venv)
51
+ bin/logica install
52
+
53
+ # compile everything in logica/config.yml to digested SQL + manifest
54
+ bin/logica compile
55
+
56
+ # compile a single query
57
+ bin/logica compile hello_world
58
+
59
+ # watch logica/programs/**/*.l and recompile on change
60
+ bin/logica watch
61
+ ```
62
+
63
+ ### Using a system-installed Python `logica`
64
+
65
+ If you install Python Logica globally (and `logica` is on `PATH`), `bin/logica compile` will use it automatically.
66
+ You can still force a specific executable path via `LOGICA_BIN`:
67
+
68
+ ```bash
69
+ python -m pip install logica==1.3.1415926535897
70
+ bin/logica compile
71
+
72
+ # or override explicitly
73
+ LOGICA_BIN=/usr/local/bin/logica bin/logica compile
74
+ ```
75
+
76
+ ## Configuration (`logica/config.yml`)
77
+
78
+ Example:
79
+
80
+ ```yaml
81
+ engine: postgres
82
+ output_dir: logica/compiled
83
+
84
+ queries:
85
+ hello_world:
86
+ program: logica/programs/hello_world.l
87
+ predicate: Greet
88
+ ```
89
+
90
+ Notes:
91
+
92
+ - If your program declares `@Engine("...");`, it must match `engine` in config.
93
+ - If you omit `@Engine(...)`, the compiler will prepend `@Engine("<engine>");` automatically.
94
+
95
+ ## Running compiled queries in Rails
96
+
97
+ After compilation, the initializer provides:
98
+
99
+ - `Rails.application.config.x.logica.registry`
100
+ - `Rails.application.config.x.logica.runner`
101
+
102
+ Example:
103
+
104
+ ```ruby
105
+ Rails.application.config.x.logica.runner.exec(:hello_world, statement_timeout: nil)
106
+ ```
107
+
108
+ ## Development (inside this repo)
109
+
110
+ Run gem tests:
111
+
112
+ ```bash
113
+ bundle install --all
114
+ bundle exec rake
115
+ ```
116
+
117
+ The SQLite e2e test (`test/sqlite_e2e_test.rb`) will **skip** if:
118
+
119
+ - ActiveRecord/sqlite3 gems are unavailable, or
120
+ - Python `logica` CLI cannot be found (via `LOGICA_BIN` or on PATH)
121
+
122
+ CI installs Python Logica so the e2e test runs there.
123
+
124
+ ## Environment variables
125
+
126
+ - `LOGICA_BIN`: path or command name of the Python `logica` executable (optional; defaults to `.venv/bin/logica` if present, otherwise `logica` on `PATH`, otherwise `tmp/logica_venv/.../logica`)
127
+ - `LOGICA_COMPILE_TIMEOUT`: compile timeout seconds (default 30)
128
+ - `LOGICA_PYTHON`, `LOGICA_REQUIREMENTS`, `LOGICA_VENV`: advanced install overrides for the CLI
129
+ - `FORCE=1`: for `rake logica_compiler:install` only (overwrite generated files if they already exist)
130
+
131
+ For compilation, use CLI flags instead:
132
+
133
+ - `bin/logica compile --force`
134
+ - `bin/logica compile --no-prune`
135
+
136
+ ## License
137
+
138
+ MIT. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
13
+
14
+ desc "Run tests with a lib/ coverage summary (local use; not wired into CI)"
15
+ task :coverage do
16
+ ENV["MT_NO_PLUGINS"] = "1"
17
+
18
+ require "coverage"
19
+ Coverage.start
20
+
21
+ lib_root = File.expand_path("lib", __dir__)
22
+ test_root = File.expand_path("test", __dir__)
23
+
24
+ $LOAD_PATH.unshift(lib_root) unless $LOAD_PATH.include?(lib_root)
25
+ $LOAD_PATH.unshift(test_root) unless $LOAD_PATH.include?(test_root)
26
+
27
+ # Load all gem library files so untested files show up in the report too.
28
+ Dir[File.join(lib_root, "**/*.rb")].sort.each do |path|
29
+ rel = path.sub("#{lib_root}/", "").sub(/\.rb\z/, "")
30
+ next if rel == "logica_compiler/railtie" # Rails is optional in this gem.
31
+
32
+ require rel
33
+ end
34
+
35
+ require "test_helper"
36
+
37
+ Dir[File.join(test_root, "**/*_test.rb")].sort.each do |path|
38
+ next if path.end_with?("test_helper.rb")
39
+
40
+ require path
41
+ end
42
+
43
+ Minitest.after_run do
44
+ result = Coverage.result
45
+ lib_prefix = "#{lib_root}#{File::SEPARATOR}"
46
+
47
+ rows = []
48
+ result.each do |path, counts|
49
+ next unless path.start_with?(lib_prefix)
50
+ next unless counts.is_a?(Array)
51
+
52
+ total = 0
53
+ covered = 0
54
+ counts.each do |c|
55
+ next if c.nil?
56
+ total += 1
57
+ covered += 1 if c.positive?
58
+ end
59
+
60
+ pct = total.positive? ? (covered * 100.0 / total) : 0
61
+ rel = path.sub("#{File.expand_path(__dir__)}#{File::SEPARATOR}", "")
62
+ rows << [pct, covered, total, rel]
63
+ end
64
+
65
+ rows.sort_by!(&:first)
66
+ puts "\nlib/ coverage by file (low → high):"
67
+ rows.each do |pct, covered, total, rel|
68
+ puts format("%6.1f%% %4d/%-4d %s", pct, covered, total, rel)
69
+ end
70
+
71
+ total_covered = rows.sum { |r| r[1] }
72
+ total_lines = rows.sum { |r| r[2] }
73
+ total_pct = total_lines.positive? ? (total_covered * 100.0 / total_lines) : 0
74
+ puts format("\nTOTAL: %d/%d (%.1f%%)\n", total_covered, total_lines, total_pct)
75
+ end
76
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "logica_compiler"
5
+ require "logica_compiler/cli"
6
+
7
+ LogicaCompiler::CLI.start(ARGV)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require_relative "../sql_dialect/postgres"
6
+ require_relative "../sql_dialect/sqlite"
7
+
8
+ module LogicaCompiler
9
+ module ActiveRecord
10
+ class Runner
11
+ DEFAULT_STATEMENT_TIMEOUT_MS = 3000
12
+
13
+ def initialize(registry:, connection: nil, connection_pool: ::ActiveRecord::Base.connection_pool, dialect: nil)
14
+ @registry = registry
15
+ @connection = connection
16
+ @connection_pool = connection_pool
17
+ @dialect = dialect
18
+ end
19
+
20
+ def exec(name, filters: {}, limit: nil, statement_timeout: DEFAULT_STATEMENT_TIMEOUT_MS)
21
+ return exec_with_connection(@connection, name, filters:, limit:, statement_timeout:) if @connection
22
+
23
+ @connection_pool.with_connection do |conn|
24
+ exec_with_connection(conn, name, filters:, limit:, statement_timeout:)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def exec_with_connection(conn, name, filters:, limit:, statement_timeout:)
31
+ dialect = @dialect || dialect_for(conn)
32
+ sql, binds = wrap_sql(@registry.sql(name), filters:, limit:, connection: conn, dialect:)
33
+ label = "Logica/#{name}"
34
+
35
+ timeout = format_statement_timeout(statement_timeout)
36
+ timeout_sql = timeout && dialect.statement_timeout_sql(conn, timeout)
37
+
38
+ return conn.exec_query(sql, label, binds) unless timeout_sql
39
+
40
+ conn.transaction do
41
+ conn.execute(timeout_sql)
42
+ conn.exec_query(sql, label, binds)
43
+ end
44
+ end
45
+
46
+ def dialect_for(conn)
47
+ adapter = conn.adapter_name.to_s.downcase
48
+ return SqlDialect::Sqlite.new if adapter.include?("sqlite")
49
+
50
+ SqlDialect::Postgres.new
51
+ end
52
+
53
+ def wrap_sql(base_sql, filters:, limit:, connection:, dialect:)
54
+ where_clauses = []
55
+ binds = []
56
+ idx = 1
57
+
58
+ filters.each do |key, value|
59
+ column = key.to_s
60
+ raise ArgumentError, "Invalid filter column: #{column.inspect}" unless column.match?(/\A[a-z_][a-z0-9_]*\z/i)
61
+
62
+ where_clauses << "t.#{connection.quote_column_name(column)} = #{dialect.placeholder(idx)}"
63
+ binds << ::ActiveRecord::Relation::QueryAttribute.new(
64
+ column,
65
+ value,
66
+ ::ActiveRecord::Type::Value.new
67
+ )
68
+ idx += 1
69
+ end
70
+
71
+ base_sql = base_sql.to_s.strip.sub(/;\s*\z/, "")
72
+ sql = +"SELECT * FROM (#{base_sql}) AS t"
73
+ sql << " WHERE " << where_clauses.join(" AND ") unless where_clauses.empty?
74
+ sql << " LIMIT #{Integer(limit)}" if limit
75
+
76
+ [sql, binds]
77
+ end
78
+
79
+ def format_statement_timeout(value)
80
+ return nil if value.nil?
81
+ return "#{Integer(value)}ms" if value.is_a?(Integer) || value.to_s.match?(/\A\d+\z/)
82
+
83
+ value.to_s
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ require_relative "commands/clean"
6
+ require_relative "commands/compile"
7
+ require_relative "commands/install"
8
+ require_relative "commands/version"
9
+ require_relative "commands/watch"
10
+
11
+ module LogicaCompiler
12
+ class CLI < Thor
13
+ default_command :help
14
+
15
+ class_option :config,
16
+ type: :string,
17
+ default: "logica/config.yml",
18
+ desc: "Path to logica config.yml"
19
+
20
+ class_option :logica_bin,
21
+ type: :string,
22
+ desc: "Override LOGICA_BIN (path or command name)"
23
+
24
+ desc "install", "Install pinned Logica CLI into tmp/logica_venv (no-op if LOGICA_BIN points elsewhere)"
25
+ def install
26
+ with_cli_errors do
27
+ version = Commands::Install.new.call(config_path: options[:config], logica_bin_override: options[:logica_bin])
28
+ say version, :green
29
+ end
30
+ end
31
+
32
+ desc "compile [NAME]", "Compile Logica programs to digested SQL + manifest (optional single query NAME)"
33
+ method_option :force, type: :boolean, default: false, desc: "Recompile even if digest matches"
34
+ method_option :prune, type: :boolean, default: true, desc: "Remove old digested artifacts for the same query name"
35
+ def compile(name = nil)
36
+ with_cli_errors do
37
+ Commands::Compile.new.call(
38
+ config_path: options[:config],
39
+ logica_bin_override: options[:logica_bin],
40
+ name:,
41
+ force: options[:force],
42
+ prune: options[:prune]
43
+ )
44
+ end
45
+ end
46
+
47
+ desc "clean", "Remove compiled SQL artifacts (keeps logica/compiled/.keep)"
48
+ def clean
49
+ with_cli_errors do
50
+ Commands::Clean.new.call(config_path: options[:config], logica_bin_override: options[:logica_bin])
51
+ end
52
+ end
53
+
54
+ desc "watch", "Watch logica/programs and auto-compile on change (development only)"
55
+ def watch
56
+ with_cli_errors do
57
+ Commands::Watch.new.call(config_path: options[:config], logica_bin_override: options[:logica_bin])
58
+ end
59
+ end
60
+
61
+ desc "version", "Print pinned/installed Logica version"
62
+ def version
63
+ with_cli_errors do
64
+ say Commands::Version.new.call(config_path: options[:config], logica_bin_override: options[:logica_bin])
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def with_cli_errors
71
+ yield
72
+ rescue LogicaCompiler::Error => e
73
+ raise Thor::Error, e.message
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../deps/clock"
5
+ require_relative "../deps/env"
6
+ require_relative "../deps/shell"
7
+ require_relative "../util"
8
+
9
+ module LogicaCompiler
10
+ module Commands
11
+ class Base
12
+ def initialize(
13
+ env: Deps::Env.new,
14
+ shell: Deps::Shell.new,
15
+ clock: Deps::Clock.new,
16
+ config_loader: Config.method(:load!),
17
+ which: Util.method(:which)
18
+ )
19
+ @env = env
20
+ @shell = shell
21
+ @clock = clock
22
+ @config_loader = config_loader
23
+ @which = which
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :env, :shell, :clock, :config_loader, :which
29
+
30
+ def with_logica_bin_override(override)
31
+ value = override.to_s
32
+ value = nil if value.strip.empty?
33
+ return yield if value.nil?
34
+
35
+ env.with_temp("LOGICA_BIN" => value) { yield }
36
+ end
37
+
38
+ def load_config(config_path:, logica_bin_override: nil)
39
+ with_logica_bin_override(logica_bin_override) do
40
+ config_loader.call(config_path)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "base"
6
+
7
+ module LogicaCompiler
8
+ module Commands
9
+ class Clean < Base
10
+ def call(config_path:, logica_bin_override: nil)
11
+ config = load_config(config_path:, logica_bin_override:)
12
+ dir = config.output_dir_path
13
+ FileUtils.mkdir_p(dir)
14
+
15
+ Dir.glob(dir.join("*").to_s).each do |path|
16
+ next if File.basename(path) == ".keep"
17
+
18
+ FileUtils.rm_rf(path)
19
+ end
20
+
21
+ true
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compiler"
4
+ require_relative "../manifest"
5
+ require_relative "base"
6
+ require_relative "logica"
7
+
8
+ module LogicaCompiler
9
+ module Commands
10
+ class Compile < Base
11
+ def initialize(compiler_factory: ->(config) { Compiler.new(config:) }, **kwargs)
12
+ super(**kwargs)
13
+ @logica = Logica.new(**kwargs)
14
+ @compiler_factory = compiler_factory
15
+ end
16
+
17
+ def call(config_path:, logica_bin_override: nil, name: nil, force: false, prune: true)
18
+ config = load_config(config_path:, logica_bin_override:)
19
+ @logica.ensure_available!(config, allow_install: true)
20
+
21
+ compiler = @compiler_factory.call(config)
22
+
23
+ if name.to_s.strip.empty?
24
+ compiler.compile_all!(force:, prune:)
25
+ else
26
+ compile_one_and_update_manifest!(compiler, config, name.to_s, force:, prune:)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def compile_one_and_update_manifest!(compiler, config, name, force:, prune:)
33
+ sym = name.to_sym
34
+ unless config.queries.key?(sym)
35
+ raise UnknownQueryError, "Unknown Logica query: #{name}. Known: #{config.queries.keys.map(&:to_s).sort.join(", ")}"
36
+ end
37
+
38
+ entry = compiler.compile_one!(name:, query: config.queries.fetch(sym), force:, prune:)
39
+
40
+ manifest =
41
+ begin
42
+ Manifest.load(config.manifest_path)
43
+ rescue StandardError
44
+ Manifest.build(engine: config.engine, logica_version: compiler.logica_version, queries: {})
45
+ end
46
+
47
+ manifest["engine"] = config.engine
48
+ manifest["logica"] = { "pypi" => "logica", "version" => compiler.logica_version.to_s }
49
+ manifest["compiled_at"] = clock.now_utc.iso8601
50
+ manifest["queries"] ||= {}
51
+ manifest["queries"][name.to_s] = entry
52
+ Manifest.write!(config.manifest_path, manifest)
53
+ manifest
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "logica"
5
+
6
+ module LogicaCompiler
7
+ module Commands
8
+ class Install < Base
9
+ def initialize(**kwargs)
10
+ super
11
+ @logica = Logica.new(**kwargs)
12
+ end
13
+
14
+ def call(config_path:, logica_bin_override: nil)
15
+ config = load_config(config_path:, logica_bin_override:)
16
+ @logica.ensure_available!(config, allow_install: true)
17
+ config.pinned_logica_version || "unknown"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ require_relative "base"
8
+
9
+ module LogicaCompiler
10
+ module Commands
11
+ class Logica < Base
12
+ DEFAULT_PYTHON = "python3"
13
+
14
+ def ensure_available!(config, allow_install:)
15
+ bin = config.logica_bin.to_s
16
+
17
+ if Util.path_like?(bin)
18
+ return if File.executable?(bin)
19
+
20
+ if allow_install && bin == default_venv_logica_path(config).to_s
21
+ install_venv_logica!(config)
22
+ return if File.executable?(bin)
23
+ end
24
+
25
+ raise LogicaError, "Logica CLI not found: #{bin.inspect}"
26
+ end
27
+
28
+ return if which.call(bin)
29
+
30
+ raise LogicaError, "Logica CLI #{bin.inspect} not found on PATH. Run `bin/logica install` or set LOGICA_BIN."
31
+ end
32
+
33
+ def default_venv_logica_path(config)
34
+ config.root.join("tmp/logica_venv", Util.venv_bin_dir, "logica")
35
+ end
36
+
37
+ def install_venv_logica!(config)
38
+ python = env.fetch("LOGICA_PYTHON", DEFAULT_PYTHON)
39
+ requirements = Pathname(env.fetch("LOGICA_REQUIREMENTS", config.requirements_path.to_s))
40
+ venv_dir = Pathname(env.fetch("LOGICA_VENV", config.root.join("tmp/logica_venv").to_s))
41
+
42
+ raise LogicaError, "#{python} not found. Install Python 3 and try again." unless which.call(python)
43
+ raise LogicaError, "requirements not found: #{requirements}" unless requirements.exist?
44
+
45
+ FileUtils.mkdir_p(venv_dir.parent)
46
+ shell.system!(python, "-m", "venv", venv_dir.to_s) unless venv_dir.exist?
47
+
48
+ stamp = venv_dir.join(".requirements.sha256")
49
+ req_sha = Digest::SHA256.hexdigest(requirements.read)
50
+
51
+ return if stamp.exist? && stamp.read.strip == req_sha
52
+
53
+ venv_python = venv_dir.join(Util.venv_bin_dir, "python").to_s
54
+ shell.system!(venv_python, "-m", "pip", "install", "--upgrade", "pip")
55
+ shell.system!(venv_python, "-m", "pip", "install", "-r", requirements.to_s)
56
+
57
+ stamp.write(req_sha)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module LogicaCompiler
6
+ module Commands
7
+ class Version < Base
8
+ def call(config_path:, logica_bin_override: nil)
9
+ config = load_config(config_path:, logica_bin_override:)
10
+ config.pinned_logica_version || "unknown"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../watcher"
4
+ require_relative "base"
5
+ require_relative "logica"
6
+
7
+ module LogicaCompiler
8
+ module Commands
9
+ class Watch < Base
10
+ def initialize(watcher: Watcher, **kwargs)
11
+ super(**kwargs)
12
+ @watcher = watcher
13
+ @logica = Logica.new(**kwargs)
14
+ end
15
+
16
+ def call(config_path:, logica_bin_override: nil)
17
+ config = load_config(config_path:, logica_bin_override:)
18
+ @logica.ensure_available!(config, allow_install: true)
19
+ @watcher.start!(config_path:)
20
+ end
21
+ end
22
+ end
23
+ end