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
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "yaml"
7
+
8
+ module LogicaCompiler
9
+ class Installer
10
+ PINNED_LOGICA_VERSION = "1.3.1415926535897"
11
+
12
+ DEFAULT_ENGINE = "postgres"
13
+ DEFAULT_OUTPUT_DIR = "logica/compiled"
14
+
15
+ SAMPLE_PROGRAM_REL = "logica/programs/hello_world.l"
16
+ SAMPLE_QUERY_NAME = "hello_world"
17
+ SAMPLE_PREDICATE = "Greet"
18
+
19
+ def initialize(root:, force: false, stdout: $stdout, stderr: $stderr)
20
+ @root = Pathname(root)
21
+ @force = !!force
22
+ @stdout = stdout
23
+ @stderr = stderr
24
+ @conflicts = []
25
+ end
26
+
27
+ def run!
28
+ ensure_dir(@root.join("logica/programs"))
29
+ ensure_dir(@root.join("logica/compiled"))
30
+
31
+ ensure_file(@root.join("logica/programs/.keep"), "", mode: nil)
32
+ ensure_file(@root.join("logica/compiled/.keep"), "", mode: nil)
33
+
34
+ ensure_template(@root.join(SAMPLE_PROGRAM_REL), "hello_world.l", mode: nil)
35
+ ensure_config_yml
36
+ ensure_requirements
37
+ ensure_gitignore_block
38
+ ensure_template(@root.join("bin/logica"), "bin_logica", mode: 0o755)
39
+ ensure_template(@root.join("config/initializers/logica_compiler.rb"), "initializer.rb", mode: nil)
40
+
41
+ if @conflicts.any?
42
+ @stderr.puts "\nConflicts detected. No files were overwritten."
43
+ @stderr.puts @conflicts.map { |p| " - #{p}" }.join("\n")
44
+ @stderr.puts "\nHow to resolve:"
45
+ @stderr.puts " - Re-run with FORCE=1 to overwrite conflicting files"
46
+ @stderr.puts " - Or manually merge the templates and re-run"
47
+ @stderr.puts "Template source: #{template_root}"
48
+ raise InstallError, "LogicaCompiler install aborted due to conflicts (FORCE=1 to overwrite)."
49
+ end
50
+
51
+ say("done", "Installed LogicaCompiler integration into #{@root}")
52
+ say("next", "bin/logica install")
53
+ say("next", "bin/logica compile")
54
+ true
55
+ end
56
+
57
+ private
58
+
59
+ def template_root
60
+ Pathname(__dir__).join("templates")
61
+ end
62
+
63
+ def template_vars
64
+ {
65
+ engine: DEFAULT_ENGINE,
66
+ output_dir: DEFAULT_OUTPUT_DIR,
67
+ sample_program_rel: SAMPLE_PROGRAM_REL,
68
+ sample_query_name: SAMPLE_QUERY_NAME,
69
+ sample_predicate: SAMPLE_PREDICATE,
70
+ pinned_logica_version: PINNED_LOGICA_VERSION,
71
+ }
72
+ end
73
+
74
+ def render_template(name)
75
+ path = template_root.join("#{name}.erb")
76
+ ERB.new(path.read, trim_mode: "-").result_with_hash(template_vars)
77
+ end
78
+
79
+ def ensure_dir(path)
80
+ path = Pathname(path)
81
+ return say("exist", path.to_s) if path.exist?
82
+
83
+ FileUtils.mkdir_p(path)
84
+ say("create", path.to_s)
85
+ end
86
+
87
+ def ensure_template(dest, template_name, mode:)
88
+ content = render_template(template_name)
89
+ ensure_file(dest, content, mode:)
90
+ end
91
+
92
+ def ensure_file(dest, content, mode:)
93
+ dest = Pathname(dest)
94
+
95
+ if dest.exist?
96
+ existing = dest.read
97
+ return say("identical", dest.to_s) if existing == content
98
+
99
+ if @force
100
+ write_file(dest, content, mode:)
101
+ return say("force", dest.to_s)
102
+ end
103
+
104
+ say("conflict", dest.to_s)
105
+ @conflicts << dest.to_s
106
+ return :conflict
107
+ end
108
+
109
+ write_file(dest, content, mode:)
110
+ say("create", dest.to_s)
111
+ :create
112
+ end
113
+
114
+ def write_file(dest, content, mode:)
115
+ FileUtils.mkdir_p(dest.dirname)
116
+ dest.write(content)
117
+ File.chmod(mode, dest.to_s) if mode
118
+ end
119
+
120
+ def ensure_config_yml
121
+ path = @root.join("logica/config.yml")
122
+
123
+ if !path.exist? || @force
124
+ return ensure_template(path, "config.yml", mode: nil)
125
+ end
126
+
127
+ begin
128
+ data = YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
129
+ data = data.to_h
130
+ rescue StandardError => e
131
+ say("conflict", path.to_s)
132
+ @stderr.puts "Could not parse #{path} (#{e.class}: #{e.message}). Please merge manually or re-run with FORCE=1."
133
+ @conflicts << path.to_s
134
+ return :conflict
135
+ end
136
+
137
+ changed = false
138
+ unless data["engine"]
139
+ data["engine"] = DEFAULT_ENGINE
140
+ changed = true
141
+ end
142
+ unless data["output_dir"]
143
+ data["output_dir"] = DEFAULT_OUTPUT_DIR
144
+ changed = true
145
+ end
146
+
147
+ queries = data["queries"]
148
+ queries = {} unless queries.is_a?(Hash)
149
+ unless queries.key?(SAMPLE_QUERY_NAME)
150
+ queries[SAMPLE_QUERY_NAME] = { "program" => SAMPLE_PROGRAM_REL, "predicate" => SAMPLE_PREDICATE }
151
+ data["queries"] = queries
152
+ changed = true
153
+ end
154
+
155
+ return say("identical", path.to_s) unless changed
156
+
157
+ yaml = YAML.dump(data)
158
+ yaml = yaml.sub(/\A---\s*\n/, "")
159
+ yaml << "\n" unless yaml.end_with?("\n")
160
+ write_file(path, yaml, mode: nil)
161
+ say("update", path.to_s)
162
+ :update
163
+ end
164
+
165
+ def ensure_requirements
166
+ path = @root.join("logica/requirements.txt")
167
+ desired = render_template("requirements.txt")
168
+
169
+ if path.exist? && !@force
170
+ if path.read.include?("logica==")
171
+ return say("identical", path.to_s)
172
+ end
173
+
174
+ say("conflict", path.to_s)
175
+ @stderr.puts "requirements.txt exists but does not pin logica. Add:\n #{desired.strip}\nOr re-run with FORCE=1."
176
+ @conflicts << path.to_s
177
+ return :conflict
178
+ end
179
+
180
+ ensure_file(path, desired, mode: nil)
181
+ end
182
+
183
+ def ensure_gitignore_block
184
+ path = @root.join(".gitignore")
185
+ existed = path.exist?
186
+ existing = existed ? path.read : +""
187
+
188
+ block = render_template("gitignore_block")
189
+ return say("identical", path.to_s) if existing.include?("/logica/compiled/*")
190
+
191
+ updated = existing.dup
192
+ updated << "\n" unless updated.end_with?("\n") || updated.empty?
193
+ updated << block
194
+
195
+ write_file(path, updated, mode: nil)
196
+ say(existed ? "update" : "create", path.to_s)
197
+ existed ? :update : :create
198
+ end
199
+
200
+ def say(status, message)
201
+ @stdout.puts format("%-10s %s", status, message)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module LogicaCompiler
8
+ class Manifest
9
+ VERSION = 1
10
+
11
+ def self.build(engine:, logica_version:, queries:, compiled_at: Time.now.utc)
12
+ {
13
+ "version" => VERSION,
14
+ "engine" => engine.to_s,
15
+ "logica" => { "pypi" => "logica", "version" => logica_version.to_s },
16
+ "compiled_at" => compiled_at.iso8601,
17
+ "queries" => queries.transform_keys(&:to_s),
18
+ }
19
+ end
20
+
21
+ def self.load(path)
22
+ JSON.parse(File.read(path.to_s))
23
+ end
24
+
25
+ def self.write!(path, manifest)
26
+ path = path.to_s
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ File.write(path, "#{JSON.pretty_generate(manifest)}\n")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module LogicaCompiler
6
+ class Railtie < ::Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("tasks/logica_compiler.rake", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LogicaCompiler
6
+ class Registry
7
+ class MissingManifestError < StandardError; end
8
+ class InvalidManifestError < StandardError; end
9
+ class MissingQueryError < KeyError; end
10
+
11
+ def self.unavailable(message:)
12
+ Unavailable.new(message: message.to_s)
13
+ end
14
+
15
+ def self.load(manifest_path:, output_dir:, strict: false)
16
+ new(manifest_path:, output_dir:).tap { _1.load!(strict:) }
17
+ rescue Errno::ENOENT, MissingManifestError, InvalidManifestError => e
18
+ raise if strict
19
+
20
+ return unavailable(message: "#{e.message}. Run `bin/logica compile` again.") if e.is_a?(InvalidManifestError)
21
+
22
+ MissingManifest.new(manifest_path:)
23
+ end
24
+
25
+ def initialize(manifest_path:, output_dir:)
26
+ @manifest_path = manifest_path.to_s
27
+ @output_dir = output_dir.to_s
28
+ @entries = nil
29
+ @sql_cache = {}
30
+ end
31
+
32
+ def load!(strict: false)
33
+ data = JSON.parse(File.read(@manifest_path))
34
+ queries = data.is_a?(Hash) ? data["queries"] : nil
35
+
36
+ unless queries.is_a?(Hash)
37
+ raise InvalidManifestError, "Invalid Logica manifest #{@manifest_path}: missing or invalid 'queries' key"
38
+ end
39
+
40
+ @entries = queries
41
+ self
42
+ rescue Errno::ENOENT
43
+ raise MissingManifestError, "Missing Logica manifest: #{@manifest_path}" if strict
44
+
45
+ raise
46
+ rescue JSON::ParserError => e
47
+ raise InvalidManifestError, "Invalid Logica manifest #{@manifest_path}: #{e.message}"
48
+ end
49
+
50
+ def sql(name)
51
+ name = name.to_s
52
+ entry = entries.fetch(name) { raise MissingQueryError, "Unknown Logica query: #{name}" }
53
+ sql_file = entry.fetch("sql")
54
+ @sql_cache[name] ||= File.read(File.join(@output_dir, sql_file))
55
+ end
56
+
57
+ def entry(name)
58
+ entries.fetch(name.to_s) { raise MissingQueryError, "Unknown Logica query: #{name}" }
59
+ end
60
+
61
+ private
62
+
63
+ def entries
64
+ @entries || raise(MissingManifestError, "Registry not loaded")
65
+ end
66
+
67
+ class MissingManifest
68
+ def initialize(manifest_path:)
69
+ @manifest_path = manifest_path.to_s
70
+ end
71
+
72
+ def sql(_name)
73
+ raise MissingManifestError,
74
+ "Missing Logica manifest: #{@manifest_path}. Run `bin/logica compile` first."
75
+ end
76
+
77
+ def entry(_name)
78
+ raise MissingManifestError,
79
+ "Missing Logica manifest: #{@manifest_path}. Run `bin/logica compile` first."
80
+ end
81
+ end
82
+
83
+ class Unavailable
84
+ def initialize(message:)
85
+ @message = message.to_s
86
+ end
87
+
88
+ def sql(_name)
89
+ raise MissingManifestError, @message
90
+ end
91
+
92
+ def entry(_name)
93
+ raise MissingManifestError, @message
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module SqlDialect
5
+ class Postgres
6
+ def placeholder(index) = "$#{index}"
7
+
8
+ def statement_timeout_sql(connection, timeout)
9
+ "SET LOCAL statement_timeout = #{connection.quote(timeout)}"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module SqlDialect
5
+ class Sqlite
6
+ def placeholder(_index) = "?"
7
+
8
+ def statement_timeout_sql(_connection, _timeout)
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ class UnsafeSqlError < StandardError; end
5
+
6
+ module SqlSafety
7
+ PROHIBITED_KEYWORDS = %w[
8
+ INSERT UPDATE DELETE MERGE
9
+ CREATE ALTER DROP TRUNCATE
10
+ GRANT REVOKE
11
+ ].freeze
12
+
13
+ module_function
14
+
15
+ def validate!(sql)
16
+ stripped = strip_leading_comments(sql.to_s).lstrip
17
+ raise UnsafeSqlError, "SQL is empty" if stripped.empty?
18
+
19
+ raise UnsafeSqlError, "Unsafe SQL: must start with SELECT/WITH" unless stripped.match?(/\A(?:WITH|SELECT)\b/i)
20
+
21
+ sanitized = strip_strings_and_comments(stripped)
22
+
23
+ raise UnsafeSqlError, "Unsafe SQL: contains semicolon (multi-statement risk)" if sanitized.include?(";")
24
+
25
+ if sanitized.match?(/\b(?:#{PROHIBITED_KEYWORDS.join("|")})\b/i)
26
+ raise UnsafeSqlError, "Unsafe SQL: contains prohibited keyword"
27
+ end
28
+
29
+ if sanitized.match?(/\bFOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i)
30
+ raise UnsafeSqlError, "Unsafe SQL: contains row locking clause"
31
+ end
32
+
33
+ true
34
+ end
35
+
36
+ def strip_leading_comments(sql)
37
+ s = sql.lstrip
38
+
39
+ loop do
40
+ if s.start_with?("--")
41
+ s = s.sub(/\A--.*(?:\n|\z)/, "").lstrip
42
+ next
43
+ end
44
+
45
+ if s.start_with?("/*")
46
+ s = s.sub(%r{\A/\*.*?\*/}m, "").lstrip
47
+ next
48
+ end
49
+
50
+ break
51
+ end
52
+
53
+ s
54
+ end
55
+
56
+ def strip_strings_and_comments(sql)
57
+ s = sql.to_s
58
+ out = +""
59
+
60
+ i = 0
61
+ state = :normal
62
+ dollar_delim = nil
63
+
64
+ while i < s.length
65
+ ch = s[i]
66
+
67
+ case state
68
+ when :normal
69
+ if ch == "-" && s[i + 1] == "-"
70
+ state = :line_comment
71
+ out << " "
72
+ i += 2
73
+ elsif ch == "/" && s[i + 1] == "*"
74
+ state = :block_comment
75
+ out << " "
76
+ i += 2
77
+ elsif ch == "'"
78
+ state = :single_quote
79
+ out << " "
80
+ i += 1
81
+ elsif ch == "\""
82
+ state = :double_quote
83
+ out << " "
84
+ i += 1
85
+ elsif ch == "$"
86
+ delim = parse_dollar_delimiter(s, i)
87
+ if delim
88
+ dollar_delim = delim
89
+ state = :dollar_quote
90
+ out << (" " * delim.length)
91
+ i += delim.length
92
+ else
93
+ out << ch
94
+ i += 1
95
+ end
96
+ else
97
+ out << ch
98
+ i += 1
99
+ end
100
+ when :line_comment
101
+ if ch == "\n"
102
+ state = :normal
103
+ out << "\n"
104
+ else
105
+ out << " "
106
+ end
107
+ i += 1
108
+ when :block_comment
109
+ if ch == "*" && s[i + 1] == "/"
110
+ state = :normal
111
+ out << " "
112
+ i += 2
113
+ else
114
+ out << " "
115
+ i += 1
116
+ end
117
+ when :single_quote
118
+ if ch == "'"
119
+ if s[i + 1] == "'"
120
+ out << " "
121
+ i += 2
122
+ else
123
+ state = :normal
124
+ out << " "
125
+ i += 1
126
+ end
127
+ else
128
+ out << " "
129
+ i += 1
130
+ end
131
+ when :double_quote
132
+ if ch == "\""
133
+ if s[i + 1] == "\""
134
+ out << " "
135
+ i += 2
136
+ else
137
+ state = :normal
138
+ out << " "
139
+ i += 1
140
+ end
141
+ else
142
+ out << " "
143
+ i += 1
144
+ end
145
+ when :dollar_quote
146
+ if dollar_delim && s[i, dollar_delim.length] == dollar_delim
147
+ out << (" " * dollar_delim.length)
148
+ i += dollar_delim.length
149
+ dollar_delim = nil
150
+ state = :normal
151
+ else
152
+ out << " "
153
+ i += 1
154
+ end
155
+ end
156
+ end
157
+
158
+ out
159
+ end
160
+
161
+ def parse_dollar_delimiter(s, start_index)
162
+ return nil unless s[start_index] == "$"
163
+
164
+ j = start_index + 1
165
+ while j < s.length && s[j] != "$"
166
+ return nil unless s[j].match?(/[A-Za-z0-9_]/)
167
+
168
+ j += 1
169
+ end
170
+
171
+ return nil unless j < s.length && s[j] == "$"
172
+
173
+ s[start_index..j]
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :logica_compiler do
4
+ desc "Install LogicaCompiler integration into the current Rails app"
5
+ task install: :environment do
6
+ require "pathname"
7
+ require "logica_compiler/installer"
8
+
9
+ root =
10
+ if defined?(Rails) && Rails.respond_to?(:root)
11
+ Pathname(Rails.root)
12
+ else
13
+ Pathname(Dir.pwd)
14
+ end
15
+
16
+ force = ENV["FORCE"] == "1"
17
+
18
+ LogicaCompiler::Installer.new(root:, force:).run!
19
+ end
20
+ end
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../config/environment"
4
+ require "logica_compiler/cli"
5
+
6
+ LogicaCompiler::CLI.start(ARGV)
@@ -0,0 +1,7 @@
1
+ engine: <%= engine %>
2
+ output_dir: <%= output_dir %>
3
+
4
+ queries:
5
+ <%= sample_query_name %>:
6
+ program: <%= sample_program_rel %>
7
+ predicate: <%= sample_predicate %>
@@ -0,0 +1,3 @@
1
+ # Logica (precompiled SQL artifacts)
2
+ /logica/compiled/*
3
+ !/logica/compiled/.keep
@@ -0,0 +1,9 @@
1
+ # Demo program for LogicaCompiler.
2
+ # Run:
3
+ # bin/logica install
4
+ # bin/logica compile <%= sample_query_name %>
5
+ #
6
+ # Then execute via Rails:
7
+ # Rails.application.config.x.logica.runner.exec(:<%= sample_query_name %>)
8
+ #
9
+ <%= sample_predicate %>(greeting: "Hello from LogicaCompiler!");
@@ -0,0 +1,28 @@
1
+ strict = Rails.env.production?
2
+
3
+ config =
4
+ begin
5
+ LogicaCompiler::Config.load!(root: Rails.root)
6
+ rescue Errno::ENOENT, Psych::SyntaxError => e
7
+ raise if strict
8
+
9
+ Rails.logger&.warn("[logica_compiler] config load failed (#{e.class}): #{e.message}")
10
+ LogicaCompiler::Config.new(config_path: "logica/config.yml", root: Rails.root)
11
+ end
12
+
13
+ registry =
14
+ if !strict && !Rails.root.join("logica/config.yml").exist?
15
+ LogicaCompiler::Registry.unavailable(
16
+ message: "Missing logica/config.yml. Run `bundle exec rake logica_compiler:install` and then `bin/logica compile`."
17
+ )
18
+ else
19
+ LogicaCompiler::Registry.load(
20
+ manifest_path: config.manifest_path,
21
+ output_dir: config.output_dir_path,
22
+ strict:,
23
+ )
24
+ end
25
+
26
+ Rails.application.config.x.logica ||= ActiveSupport::OrderedOptions.new
27
+ Rails.application.config.x.logica.registry = registry
28
+ Rails.application.config.x.logica.runner = LogicaCompiler::ActiveRecord::Runner.new(registry:)
@@ -0,0 +1 @@
1
+ logica==<%= pinned_logica_version %>
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module Util
5
+ module_function
6
+
7
+ def path_like?(value)
8
+ s = value.to_s
9
+ return false if s.empty?
10
+ return true if s.start_with?(".", "/", "~")
11
+
12
+ separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact.uniq
13
+ separators.any? { |sep| s.include?(sep) }
14
+ end
15
+
16
+ def venv_bin_dir
17
+ Gem.win_platform? ? "Scripts" : "bin"
18
+ end
19
+
20
+ def command_candidates(cmd)
21
+ cmd = cmd.to_s
22
+ candidates = [cmd]
23
+ return candidates unless Gem.win_platform? && File.extname(cmd).empty?
24
+
25
+ pathext = ENV.fetch("PATHEXT", "").split(";").reject(&:empty?)
26
+ pathext = %w[.exe .bat .cmd] if pathext.empty?
27
+ pathext.map { |ext| "#{cmd}#{ext}" }
28
+ end
29
+
30
+ def executable_file?(path)
31
+ File.file?(path) && File.executable?(path)
32
+ end
33
+
34
+ def find_executable_in_dir(dir, cmd)
35
+ dir = dir.to_s
36
+ command_candidates(cmd).each do |candidate|
37
+ path = File.join(dir, candidate)
38
+ return path if executable_file?(path)
39
+ end
40
+ nil
41
+ end
42
+
43
+ def which(cmd)
44
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
45
+ path = find_executable_in_dir(dir, cmd)
46
+ return path if path
47
+ end
48
+ nil
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ VERSION = "0.1.0"
5
+ end