gemite 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.
data/lib/gemite/cli.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "http_server"
5
+
6
+ module Gemite
7
+ # `gemite` コマンドの実装。仕様3のコマンド体系:
8
+ # gemite 開発サーバ起動 (localhost:8000)
9
+ # gemite s 開発サーバ起動
10
+ # gemite s 3000 ポート指定
11
+ # gemite start "gemite s" の正式名称
12
+ # gemite start 3000 ポート指定
13
+ # gemite version バージョン表示
14
+ # gemite help ヘルプ表示
15
+ module CLI
16
+ DEFAULT_PORT = 8000
17
+
18
+ def self.run(argv)
19
+ cmd = argv[0]
20
+
21
+ case cmd
22
+ when nil, "s", "start"
23
+ start_server(argv[1])
24
+ when "version", "--version", "-v"
25
+ print_version
26
+ when "help", "--help", "-h"
27
+ print_help
28
+ else
29
+ warn "不明なコマンドです: #{cmd}"
30
+ warn ""
31
+ print_help
32
+ exit(1)
33
+ end
34
+ end
35
+
36
+ def self.start_server(port_arg)
37
+ port = parse_port(port_arg)
38
+ Gemite::HttpServer.new(root: Dir.pwd, port: port).start
39
+ end
40
+ private_class_method :start_server
41
+
42
+ def self.parse_port(arg)
43
+ return DEFAULT_PORT if arg.nil? || arg.strip.empty?
44
+
45
+ unless arg =~ /\A\d+\z/
46
+ warn "ポート番号が不正です: #{arg}"
47
+ exit(1)
48
+ end
49
+ arg.to_i
50
+ end
51
+ private_class_method :parse_port
52
+
53
+ def self.print_version
54
+ puts "Gemite #{Gemite::VERSION}"
55
+ end
56
+ private_class_method :print_version
57
+
58
+ def self.print_help
59
+ puts <<~HELP
60
+ Gemite #{Gemite::VERSION} - HTMLファーストな小規模Webアプリ言語
61
+
62
+ 使い方:
63
+ gemite 開発サーバを起動する (http://localhost:8000)
64
+ gemite s 開発サーバを起動する
65
+ gemite s PORT ポートを指定して開発サーバを起動する
66
+ gemite start "gemite s" の正式名称
67
+ gemite start PORT ポートを指定して開発サーバを起動する
68
+ gemite version バージョンを表示する
69
+ gemite help このヘルプを表示する
70
+
71
+ 現在のディレクトリを起点に .gmt ファイルをそのままURLへマッピングします。
72
+ index.gmt -> /
73
+ login.gmt -> /login
74
+ users/profile.gmt -> /users/profile
75
+
76
+ 静的ファイルは public/ 以下に置いてください(例: public/css/style.css -> /css/style.css)。
77
+ HELP
78
+ end
79
+ private_class_method :print_help
80
+ end
81
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sqlite3_ffi"
4
+ require_relative "errors"
5
+
6
+ module Gemite
7
+ # Gemiteコード上の `db = sqlite("app.db")` で得られるオブジェクトの実体。
8
+ # 仕様18〜22の select/insert/update/delete/exec をSQLに組み立てて実行する。
9
+ class Database
10
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
11
+
12
+ def initialize(path)
13
+ @handle = SQLite3FFI::Handle.new(path)
14
+ end
15
+
16
+ # 生SQLを実行する(CREATE TABLE等、戻り値を使わない用途)。
17
+ def exec(sql)
18
+ @handle.exec(sql.to_s)
19
+ nil
20
+ end
21
+
22
+ # 全件、または where: {col: val, ...} 条件でSELECTする。
23
+ def select(table, opts = {})
24
+ table_sql = quote_identifier(table)
25
+ where_hash = opts.is_a?(Hash) ? (opts[:where] || opts["where"]) : nil
26
+ clause, params = build_where(where_hash)
27
+ sql = "SELECT * FROM #{table_sql}#{clause}"
28
+ @handle.query(sql, params)
29
+ end
30
+
31
+ # {col: val, ...} をINSERTする。
32
+ def insert(table, values)
33
+ values = values || {}
34
+ table_sql = quote_identifier(table)
35
+ cols = values.keys
36
+ return nil if cols.empty?
37
+
38
+ col_sql = cols.map { |c| quote_identifier(c) }.join(", ")
39
+ placeholders = (["?"] * cols.length).join(", ")
40
+ sql = "INSERT INTO #{table_sql} (#{col_sql}) VALUES (#{placeholders})"
41
+ result = @handle.execute(sql, cols.map { |c| values[c] })
42
+ result[:rowid]
43
+ end
44
+
45
+ # {col: val, ...} でUPDATEする。where: {col: val, ...} で対象を絞る。
46
+ def update(table, values, opts = {})
47
+ values = values || {}
48
+ table_sql = quote_identifier(table)
49
+ cols = values.keys
50
+ return 0 if cols.empty?
51
+
52
+ set_sql = cols.map { |c| "#{quote_identifier(c)} = ?" }.join(", ")
53
+ set_params = cols.map { |c| values[c] }
54
+
55
+ where_hash = opts.is_a?(Hash) ? (opts[:where] || opts["where"]) : nil
56
+ clause, where_params = build_where(where_hash)
57
+
58
+ sql = "UPDATE #{table_sql} SET #{set_sql}#{clause}"
59
+ result = @handle.execute(sql, set_params + where_params)
60
+ result[:changes]
61
+ end
62
+
63
+ # where: {col: val, ...} に一致する行をDELETEする。
64
+ def delete(table, opts = {})
65
+ table_sql = quote_identifier(table)
66
+ where_hash = opts.is_a?(Hash) ? (opts[:where] || opts["where"]) : nil
67
+ clause, params = build_where(where_hash)
68
+ sql = "DELETE FROM #{table_sql}#{clause}"
69
+ result = @handle.execute(sql, params)
70
+ result[:changes]
71
+ end
72
+
73
+ def close
74
+ @handle.close
75
+ end
76
+
77
+ private
78
+
79
+ # where: {a: 1, b: "x"} -> [" WHERE a = ? AND b = ?", [1, "x"]]
80
+ def build_where(where_hash)
81
+ return ["", []] if where_hash.nil? || where_hash.empty?
82
+
83
+ conds = []
84
+ params = []
85
+ where_hash.each do |col, val|
86
+ conds << "#{quote_identifier(col)} = ?"
87
+ params << val
88
+ end
89
+ [" WHERE #{conds.join(' AND ')}", params]
90
+ end
91
+
92
+ def quote_identifier(name)
93
+ str = name.to_s
94
+ unless IDENTIFIER.match?(str)
95
+ raise Gemite::RuntimeError, "不正なテーブル名/カラム名です: #{str.inspect}"
96
+ end
97
+
98
+ "\"#{str}\""
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemite
4
+ # 変数を保持するスコープ。
5
+ #
6
+ # スコープ規則(仕様25):
7
+ # - グローバル変数はファイル単位 -> ルートのEnvironment(boundary:false, parent:nil)を
8
+ # ファイル実行全体(<?gemite ?>ブロックをまたいでも)で共有する。
9
+ # - 関数内変数はローカル -> 関数呼び出し時に boundary: true の新しい環境を作る。
10
+ # assign(代入)はこの境界を越えて外側を書き換えない。ただし読み取り(get)は
11
+ # 外側(クロージャ)を辿れる。
12
+ # - @forのループ変数はループ内のみ有効 -> こちらはEnvironmentを増やさず、
13
+ # Interpreter側でループ変数名だけ退避/削除することで実現する(他の代入は
14
+ # 通常通り現在のスコープに作用する)。
15
+ class Environment
16
+ attr_reader :parent
17
+
18
+ def initialize(parent: nil, boundary: false)
19
+ @vars = {}
20
+ @parent = parent
21
+ @boundary = boundary
22
+ end
23
+
24
+ def boundary?
25
+ @boundary
26
+ end
27
+
28
+ def has_local?(name)
29
+ @vars.key?(name)
30
+ end
31
+
32
+ def get_local(name)
33
+ @vars[name]
34
+ end
35
+
36
+ def set_local(name, value)
37
+ @vars[name] = value
38
+ end
39
+
40
+ def delete_local(name)
41
+ @vars.delete(name)
42
+ end
43
+
44
+ # 一般的な代入。既存の束縛が(関数境界を越えない範囲で)見つかればそこを更新し、
45
+ # 見つからなければ現在のスコープに新規作成する。
46
+ def assign(name, value)
47
+ env = self
48
+ loop do
49
+ if env.has_local?(name)
50
+ env.set_local(name, value)
51
+ return value
52
+ end
53
+ break if env.boundary? || env.parent.nil?
54
+
55
+ env = env.parent
56
+ end
57
+ set_local(name, value)
58
+ value
59
+ end
60
+
61
+ # 変数の読み取り。関数境界を越えて外側(クロージャ)を辿る。
62
+ # 未定義の変数は nil (寛容な設計。GET/POST同様に「存在しなければnil」に統一)。
63
+ def get(name)
64
+ env = self
65
+ while env
66
+ return env.get_local(name) if env.has_local?(name)
67
+
68
+ env = env.parent
69
+ end
70
+ nil
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemite
4
+ # 字句解析・構文解析エラー
5
+ class SyntaxError < StandardError
6
+ attr_reader :line
7
+
8
+ def initialize(message, line: nil)
9
+ @line = line
10
+ super(line ? "#{message} (#{line}行目)" : message)
11
+ end
12
+ end
13
+
14
+ # Gemiteコード実行中のエラー。begin/catch の err にはこの例外オブジェクトが
15
+ # そのまま渡されるのではなく、to_s した文字列相当がユーザから見える値になる。
16
+ class RuntimeError < StandardError
17
+ end
18
+
19
+ # def で定義した関数から return するための内部制御シグナル。
20
+ # 例外機構を使い、ネストしたif/forを一気に抜けて関数呼び出し境界まで戻る。
21
+ class ReturnSignal < StandardError
22
+ attr_reader :value
23
+
24
+ def initialize(value)
25
+ @value = value
26
+ super("return")
27
+ end
28
+ end
29
+
30
+ # redirect() 呼び出しでリクエスト処理を打ち切り、HTTPリダイレクトを行うための内部シグナル。
31
+ class RedirectSignal < StandardError
32
+ attr_reader :location
33
+
34
+ def initialize(location)
35
+ @location = location
36
+ super("redirect")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemite
4
+ # def name(params) ... end で定義された関数の実行時表現。
5
+ # closure_env には定義時点で有効だったEnvironmentを保持し、
6
+ # 関数内から外側(グローバル等)の変数を読み取れるようにする(代入は関数内ローカルに留まる)。
7
+ class Function
8
+ attr_reader :name, :params, :body, :closure_env
9
+
10
+ def initialize(name, params, body, closure_env)
11
+ @name = name
12
+ @params = params
13
+ @body = body
14
+ @closure_env = closure_env
15
+ end
16
+
17
+ def to_s
18
+ "<function #{name}>"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "request"
5
+ require_relative "router"
6
+ require_relative "template_parser"
7
+ require_relative "interpreter"
8
+ require_relative "errors"
9
+
10
+ module Gemite
11
+ # 依存ゼロ(Ruby標準の socket のみ)で実装した小規模開発用HTTPサーバ。
12
+ # .gmt ファイルはリクエストごとに読み直してパース・実行するため、
13
+ # 編集内容が即座に反映される(再起動不要)。
14
+ class HttpServer
15
+ def initialize(root:, port: 8000, host: "0.0.0.0")
16
+ @root = File.expand_path(root)
17
+ @port = port
18
+ @host = host
19
+ @router = Router.new(@root)
20
+ end
21
+
22
+ def start
23
+ $stdout.sync = true
24
+ server = TCPServer.new(@host, @port)
25
+ puts "Gemite dev server listening on http://localhost:#{@port}"
26
+ puts "root: #{@root}"
27
+ puts "(Ctrl-C で停止)"
28
+
29
+ trap("INT") do
30
+ puts "\nシャットダウンします..."
31
+ exit(0)
32
+ end
33
+
34
+ loop do
35
+ client = server.accept
36
+ Thread.new(client) { |c| handle_client(c) }
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def handle_client(client)
43
+ request = read_request(client)
44
+ return unless request
45
+
46
+ handle_request(client, request)
47
+ rescue StandardError => e
48
+ begin
49
+ send_response(client, 500, "text/plain; charset=utf-8", "内部エラー: #{e.class}: #{e.message}")
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ ensure
54
+ begin
55
+ client.close
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ end
60
+
61
+ def read_request(client)
62
+ request_line = client.gets
63
+ return nil if request_line.nil?
64
+
65
+ method, path, = request_line.strip.split(" ")
66
+ return nil unless method && path
67
+
68
+ headers = {}
69
+ loop do
70
+ line = client.gets
71
+ break if line.nil?
72
+
73
+ line = line.strip
74
+ break if line.empty?
75
+
76
+ key, val = line.split(":", 2)
77
+ next unless key && val
78
+
79
+ headers[key.strip.downcase] = val.strip
80
+ end
81
+
82
+ body = ""
83
+ len = headers["content-length"].to_i
84
+ body = client.read(len) if len.positive?
85
+
86
+ full_path, query_str = path.split("?", 2)
87
+ query_params = Request.parse_www_form(query_str || "")
88
+
89
+ Request.new(method: method, path: full_path, query_params: query_params, headers: headers, body: body)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ def handle_request(client, request)
95
+ result = @router.resolve(request.path)
96
+ case result.type
97
+ when :gemite
98
+ handle_gemite_request(client, result.path, request)
99
+ when :static
100
+ handle_static_request(client, result.path)
101
+ else
102
+ send_response(client, 404, "text/html; charset=utf-8", not_found_html(request.path))
103
+ end
104
+ end
105
+
106
+ def handle_gemite_request(client, file_path, request)
107
+ source = File.read(file_path, encoding: "UTF-8")
108
+ nodes = TemplateParser.parse(source)
109
+ interpreter = Interpreter.new(
110
+ get_params: request.query_params,
111
+ post_params: request.form_params,
112
+ base_dir: File.dirname(file_path)
113
+ )
114
+ html = interpreter.render(nodes)
115
+ send_response(client, 200, "text/html; charset=utf-8", html)
116
+ rescue Gemite::RedirectSignal => e
117
+ send_redirect(client, e.location)
118
+ rescue Gemite::SyntaxError, Gemite::RuntimeError => e
119
+ send_response(client, 500, "text/html; charset=utf-8", error_html(e, file_path))
120
+ rescue StandardError => e
121
+ send_response(client, 500, "text/html; charset=utf-8", error_html(e, file_path))
122
+ end
123
+
124
+ def handle_static_request(client, file_path)
125
+ data = File.binread(file_path)
126
+ send_response(client, 200, @router.mime_type_for(file_path), data, binary: true)
127
+ rescue StandardError => e
128
+ send_response(client, 500, "text/plain; charset=utf-8", "静的ファイルの読み込みに失敗しました: #{e.message}")
129
+ end
130
+
131
+ def send_response(client, status, content_type, body, binary: false)
132
+ bytes = binary ? body : body.to_s.b
133
+ status_text = STATUS_TEXT[status] || "Unknown"
134
+ client.write("HTTP/1.1 #{status} #{status_text}\r\n")
135
+ client.write("Content-Type: #{content_type}\r\n")
136
+ client.write("Content-Length: #{bytes.bytesize}\r\n")
137
+ client.write("Connection: close\r\n")
138
+ client.write("\r\n")
139
+ client.write(bytes)
140
+ end
141
+
142
+ def send_redirect(client, location)
143
+ client.write("HTTP/1.1 302 Found\r\n")
144
+ client.write("Location: #{location}\r\n")
145
+ client.write("Content-Length: 0\r\n")
146
+ client.write("Connection: close\r\n")
147
+ client.write("\r\n")
148
+ end
149
+
150
+ STATUS_TEXT = {
151
+ 200 => "OK", 302 => "Found", 404 => "Not Found", 500 => "Internal Server Error"
152
+ }.freeze
153
+
154
+ def not_found_html(path)
155
+ <<~HTML
156
+ <!DOCTYPE html>
157
+ <html><head><meta charset="utf-8"><title>404 Not Found</title></head>
158
+ <body style="font-family: sans-serif; padding: 2rem;">
159
+ <h1>404 Not Found</h1>
160
+ <p><code>#{escape_html(path)}</code> に対応する .gmt ファイルが見つかりませんでした。</p>
161
+ </body></html>
162
+ HTML
163
+ end
164
+
165
+ def error_html(error, file_path)
166
+ <<~HTML
167
+ <!DOCTYPE html>
168
+ <html><head><meta charset="utf-8"><title>500 Internal Server Error</title></head>
169
+ <body style="font-family: sans-serif; padding: 2rem;">
170
+ <h1>500 Internal Server Error</h1>
171
+ <p><strong>#{escape_html(error.class.to_s)}</strong>: #{escape_html(error.message)}</p>
172
+ <p style="color:#666">ファイル: <code>#{escape_html(file_path)}</code></p>
173
+ </body></html>
174
+ HTML
175
+ end
176
+
177
+ def escape_html(str)
178
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
179
+ end
180
+ end
181
+ end