From c44e303f47fda82901736f92b75279aeaa3672f6 Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 22:16:14 +0900 Subject: [PATCH 1/9] feat(query): add Query/Filter DSL and bump version to 0.1.23 --- .github/workflows/ci.yml | 11 +- Cargo.toml | 6 +- Gemfile.lock | 2 +- README.md | 520 ++++++++++++++++++++++++++++++- lib/mq.rb | 10 +- lib/mq/query.rb | 556 ++++++++++++++++++++++++++++++++++ mq-ruby.gemspec | 4 +- spec/mq_spec.rb | 639 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1725 insertions(+), 23 deletions(-) create mode 100644 lib/mq/query.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec67f04..f5ed6ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,19 @@ on: jobs: test: - name: Test (Ruby 3.4 on ${{ matrix.os }}) + name: Test (Ruby ${{ matrix.ruby }} on ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] + ruby: ['3.3', '3.4'] + include: + - os: ubuntu-latest + ruby: head + experimental: true + + continue-on-error: ${{ matrix.experimental || false }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -26,7 +33,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: - ruby-version: '3.4' + ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Install system dependencies (Ubuntu) if: runner.os == 'Linux' diff --git a/Cargo.toml b/Cargo.toml index 6e381b0..1a079de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ name = "mq-ruby" publish = false readme = "README.md" repository = "https://github.com/harehare/mq" -version = "0.1.22" +version = "0.1.23" [lib] crate-type = ["cdylib"] @@ -18,6 +18,6 @@ name = "mq_ruby" [dependencies] magnus = {version = "0.8"} -mq-lang = "0.5.31" -mq-markdown = "0.5.31" +mq-lang = "0.6.0" +mq-markdown = "0.6.0" diff --git a/Gemfile.lock b/Gemfile.lock index 5be8e35..adcd91e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mq-ruby (0.1.22) + mq-ruby (0.1.23) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index ad64b05..db244fe 100644 --- a/README.md +++ b/README.md @@ -5,40 +5,536 @@ Ruby bindings for [mq](https://mqlang.org/), a jq-like command-line tool for processing Markdown. -## Ruby API +## Installation -Once complete, the Ruby API will look like this: +Add to your Gemfile: + +```ruby +gem 'mq-ruby' +``` + +## Basic Usage ```ruby require 'mq' -# Basic usage markdown = <<~MD # Main Title + ## Section 1 + Some content here. + ## Section 2 + More content. MD +# Run a raw mq query string result = MQ.run('.h2', markdown) -result.values.each do |heading| - puts heading -end +result.values.each { |h| puts h } +# => ## Section 1 +# => ## Section 2 + +# Access result as a single string +puts result.text # => ## Section 1 # => ## Section 2 -# With options +# Count matched nodes +puts result.length # => 2 + +# Index into results (1-based) +puts result[1] # => ## Section 1 +``` + +## Query Builder + +`MQ::Query` provides a Ruby DSL for building mq query strings programmatically. +Queries are built by chaining methods and can be passed directly to `MQ.run`. + +```ruby +# Equivalent to MQ.run('.h2', markdown) +result = MQ.run(MQ::Query.h2, markdown) + +# Chain with filters and transformations +query = MQ::Query.h2 + .select { contains("Installation") } + .to_text + +result = MQ.run(query, markdown) +``` + +### Selectors + +#### Heading Selectors + +```ruby +MQ::Query.h1 # .h1 — level-1 headings +MQ::Query.h2 # .h2 — level-2 headings +MQ::Query.h3 # .h3 +MQ::Query.h4 # .h4 +MQ::Query.h5 # .h5 +MQ::Query.h6 # .h6 +MQ::Query.heading # .heading — any heading +``` + +#### Block Element Selectors + +```ruby +MQ::Query.paragraph # .p +MQ::Query.code # .code — fenced code blocks +MQ::Query.blockquote # .blockquote +MQ::Query.hr # .hr — horizontal rules +MQ::Query.list # .[] — list items +MQ::Query.table # .table +MQ::Query.table_align # .table_align +MQ::Query.math # .math — math blocks +MQ::Query.html # .html — raw HTML blocks +MQ::Query.definition # .definition — link definitions +MQ::Query.footnote # .footnote +MQ::Query.toml # .toml — TOML front matter +MQ::Query.yaml # .yaml — YAML front matter +``` + +#### Inline Element Selectors + +```ruby +MQ::Query.text # .text +MQ::Query.strong # .strong — bold +MQ::Query.emphasis # .emphasis — italic +MQ::Query.delete # .delete — strikethrough +MQ::Query.link # .link +MQ::Query.image # .image +MQ::Query.code_inline # .code_inline +MQ::Query.math_inline # .math_inline +MQ::Query.link_ref # .link_ref +MQ::Query.image_ref # .image_ref +MQ::Query.footnote_ref # .footnote_ref +MQ::Query.line_break # .break +``` + +#### Task List Selectors + +```ruby +MQ::Query.task # .task — any task list item +MQ::Query.todo # .todo — unchecked task items +MQ::Query.done # .done — checked task items +``` + +#### Indexed Selectors + +```ruby +MQ::Query.list_at(0) # .[0] — first list item +MQ::Query.list_at(2) # .[2] — third list item +MQ::Query.table_row(0) # .[0][] — all cells in row 0 +MQ::Query.table_col(1) # .[][1] — all cells in column 1 +MQ::Query.table_cell(0, 1) # .[0][1] — cell at row 0, column 1 +``` + +#### MDX Selectors + +```ruby +MQ::Query.mdx_jsx_flow_element # .mdx_jsx_flow_element +MQ::Query.mdx_text_expression # .mdx_text_expression +MQ::Query.mdx_jsx_text_element # .mdx_jsx_text_element +MQ::Query.mdx_flow_expression # .mdx_flow_expression +MQ::Query.mdx_js_esm # .mdx_js_esm +``` + +#### Attribute Selectors + +Access specific attributes of nodes directly: + +```ruby +MQ::Query.code.lang # .code | .lang — language of code blocks +MQ::Query.link.url # .link | .url — URL of links +MQ::Query.image.alt # .image | .alt — alt text of images +MQ::Query.link.title # .link | .title — title of links + +# All available attribute selectors (class-level) +MQ::Query.value # .value +MQ::Query.lang # .lang +MQ::Query.meta # .meta +MQ::Query.fence # .fence +MQ::Query.url # .url +MQ::Query.alt # .alt +MQ::Query.depth # .depth — heading depth +MQ::Query.level # .level +MQ::Query.ordered # .ordered — list ordered flag +MQ::Query.checked # .checked — task item checked state +MQ::Query.column # .column — table cell column index +MQ::Query.row # .row — table cell row index +MQ::Query.align # .align — table alignment +``` + +#### Instance-level Attribute Access (for chaining) + +```ruby +# After selecting a node, chain attribute selectors: +MQ::Query.code.lang # .code | .lang +MQ::Query.link.url # .link | .url +MQ::Query.heading.depth # .heading | .depth +MQ::Query.task.checked # .task | .checked +MQ::Query.list.item_index # .[] | .index (item_index avoids naming conflict) +MQ::Query.list.ordered # .[] | .ordered +MQ::Query.table.column # .table | .column +MQ::Query.table.row # .table | .row +MQ::Query.table_align.align # .table_align | .align +MQ::Query.mdx_jsx_flow_element.mdx_name # .mdx_jsx_flow_element | .name +``` + +#### Recursive Selector + +```ruby +MQ::Query.recursive # .. — matches all nodes recursively +``` + +#### Dict Property Selector + +```ruby +MQ::Query.property("title") # ."title" +query.property("key") # | ."key" +``` + +### Pipe Operator + +Chain two queries with `|`: + +```ruby +query = MQ::Query.h2 | MQ::Query.to_text +# => ".h2 | to_text()" + +query = MQ::Query.h2 | MQ::Query.select { contains("API") } | MQ::Query.to_text +# => '.h2 | select(contains("API")) | to_text()' +``` + +### Filtering with `select` + +```ruby +# Block form (recommended) +MQ::Query.h2.select { contains("Feature") } +# => '.h2 | select(contains("Feature"))' + +# Combine conditions with & (and) and | (or) +MQ::Query.h2.select { contains("API") & starts_with("## ") } +# => '.h2 | select(contains("API") and starts_with("## "))' + +MQ::Query.h2.select { contains("A") | contains("B") } +# => '.h2 | select(contains("A") or contains("B"))' + +# Negation +MQ::Query.select { negate(contains("draft")) } +# => 'select(not(contains("draft")))' + +# Class-level select (no leading selector) +MQ::Query.select { is_mdx } +# => "select(is_mdx())" + +# String or Filter argument +MQ::Query.h2.select('contains("Feature")') +MQ::Query.h2.select(MQ::Filter.new('contains("Feature")')) +``` + +### Mapping with `map` + +```ruby +MQ::Query.list.map { contains("important") } +# => '.[] | map(contains("important"))' +``` + +### Transformation Methods + +#### Output + +```ruby +.to_text # to_text() — plain text +.to_markdown # to_markdown() — markdown string +.to_mdx # to_mdx() — MDX string +.to_html # to_html() — HTML string +.to_string # to_string() — string coercion +.to_number # to_number() — numeric coercion +.to_array # to_array() +.to_bytes # to_bytes() +.to_markdown_string # to_markdown_string() +``` + +#### String Operations + +```ruby +.trim # trim() +.ltrim # ltrim() +.rtrim # rtrim() +.downcase # downcase() +.upcase # upcase() +.ascii_downcase # ascii_downcase() +.ascii_upcase # ascii_upcase() +.len # len() +.utf8bytelen # utf8bytelen() +.explode # explode() — string to codepoints +.implode # implode() — codepoints to string +.url_encode # url_encode() +.intern # intern() + +.split(",") # split(",") +.gsub("pat", "r") # gsub("pat", "r") — regex replace all +.replace("a", "b") # replace("a", "b") — literal replace +.test("\\d+") # test("\\d+") — regex test → bool +.capture("(\\w+)") # capture("(\\w+)") — regex capture +.slice(0, 5) # slice(0, 5) +.index("sub") # index("sub") — position of substring +.rindex("sub") # rindex("sub") — last position +.repeat(3) # repeat(3) +``` + +#### Collection Operations + +```ruby +.length # length +.len # len() +.add # add +.first # first +.last # last +.empty # empty +.reverse # reverse +.sort # sort +.compact # compact — remove nils +.uniq # uniq +.flatten # flatten +.keys # keys +.values # values +.entries # entries +.children # .children +.join(", ") # join(", ") +.nth(2) # nth(2) +.limit(5) # limit(5) +.range(3) # range(3) +.del("key") # del("key") +.insert(0, "val") # insert(0, "val") +``` + +#### Math Operations + +```ruby +.abs # abs() +.ceil # ceil() +.floor # floor() +.round # round() +.trunc # trunc() +.sqrt # sqrt() +.ln # ln() +.log10 # log10() +.exp # exp() +.pow(2) # pow(2) +.min(0) # min(0) +.max(100) # max(100) +.negate_val # negate() — numeric negation +.is_nan # is_nan() +``` + +#### Type / Logic + +```ruby +.type # type +.coalesce("default") # coalesce("default") +.debug # debug +``` + +#### Encoding + +```ruby +.base64 # base64() +.base64d # base64d() +.base64url # base64url() +.base64urld # base64urld() +.md5 # md5() +.sha256 # sha256() +.sha512 # sha512() +.from_hex # from_hex() +.to_hex # to_hex() +``` + +#### Path Operations + +```ruby +.basename # basename() +.dirname # dirname() +.extname # extname() +.stem # stem() +.path_join("sub") # path_join("sub") +``` + +#### Dict Operations + +```ruby +.get("key") # get("key") +.set("key", "val") # set("key", "val") +.property("key") # ."key" +``` + +#### Markdown Attribute Operations + +```ruby +.update("New content") # update("New content") +.attr("lang") # attr("lang") +.set_attr("lang", "ruby") # set_attr("lang", "ruby") +.get_title # get_title +.get_url # get_url +.set_check(true) # set_check(true) +.set_ref("myref") # set_ref("myref") +.set_code_block_lang("python") # set_code_block_lang("python") +.set_list_ordered(true) # set_list_ordered(true) +``` + +#### Markdown Construction + +```ruby +.to_code("ruby") # to_code("ruby") +.to_code # to_code(null) — no language +.to_code_inline # to_code_inline() +.to_h(2) # to_h(2) — convert to heading level 2 +.to_hr # to_hr() +.to_link("url", "text", "title") # to_link(...) +.to_link("url", "text") # to_link(...) — empty title +.to_link("url") # to_link(...) — current value as text +.to_image("url", "alt", "title") # to_image(...) +.to_math # to_math() +.to_math_inline # to_math_inline() +.to_strong # to_strong() +.to_em # to_em() +.to_md_text # to_md_text() +.to_md_list(0) # to_md_list(0) — nesting level +.to_md_name("component") # to_md_name("component") +.to_md_table_row("A", "B", "C") # to_md_table_row(...) +.to_md_table_cell("val", 0, 1) # to_md_table_cell(...) +``` + +### Filter DSL + +All filter methods return a `MQ::Filter` that can be combined with `&` (and) and `|` (or). + +#### String Matching + +```ruby +contains("text") # contains("text") +starts_with("## ") # starts_with("## ") +ends_with(".") # ends_with(".") +test("\\d+") # test("\\d+") — regex test +``` + +#### Regex + +```ruby +is_regex_match("\\d+") # is_regex_match("\\d+") +is_not_regex_match("\\d+") # is_not_regex_match("\\d+") +``` + +#### Comparison Operators + +These compare the current pipeline value against the argument: + +```ruby +eq("value") # eq("value") — equal +ne("value") # ne("value") — not equal +gt(5) # gt(5) — greater than +gte(5) # gte(5) — greater than or equal +lt(5) # lt(5) — less than +lte(5) # lte(5) — less than or equal +``` + +#### Type Checks + +```ruby +is_mdx # is_mdx() +is_none # is_none() +is_nan # is_nan() +type # type +``` + +#### Other + +```ruby +negate(contains("draft")) # not(contains("draft")) +length # length +empty # empty +add # add +``` + +### Combining Filters + +```ruby +MQ::Query.h2.select { contains("API") & negate(contains("Internal")) } +# => '.h2 | select(contains("API") and not(contains("Internal")))' + +MQ::Query.h2.select { starts_with("## ") | ends_with("!") } +# => '.h2 | select(starts_with("## ") or ends_with("!"))' + +# Three-way AND +MQ::Query.h2.select { + contains("API") & negate(contains("Internal")) & starts_with("## ") +} +``` + +## Options + +```ruby options = MQ::Options.new +options.input_format = MQ::InputFormat::MARKDOWN # default +options.input_format = MQ::InputFormat::MDX +options.input_format = MQ::InputFormat::TEXT options.input_format = MQ::InputFormat::HTML +options.input_format = MQ::InputFormat::RAW +options.input_format = MQ::InputFormat::NULL + +result = MQ.run('.h1', content, options) +``` -result = MQ.run('.h1', '

Hello

World

', options) -puts result.text # => # Hello +## HTML to Markdown -# HTML to Markdown conversion -html = '

Title

Paragraph

' +```ruby +html = '

Title

This is a test.

' markdown = MQ.html_to_markdown(html) -puts markdown # => # Title\n\nParagraph +# => "# Title\n\nThis is a **test**." + +# With conversion options +options = MQ::ConversionOptions.new +options.use_title_as_h1 = true +options.extract_scripts_as_code_blocks = true +options.generate_front_matter = true + +markdown = MQ.html_to_markdown(html, options) +``` + +## Examples + +```ruby +require 'mq' + +content = File.read('README.md') + +# Extract all h2 headings containing "API" +MQ.run(MQ::Query.h2.select { contains("API") }, content).values + +# Get all code block languages used +MQ.run(MQ::Query.code.lang, content).values + +# Get all link URLs +MQ.run(MQ::Query.link.url, content).values + +# Extract headings as plain text (no # prefix) +MQ.run(MQ::Query.h2.to_text, content).values + +# Find unchecked task items +MQ.run(MQ::Query.todo, content).values + +# Get the first list item +MQ.run(MQ::Query.list_at(0), content).values + +# Count h2 headings +MQ.run(MQ::Query.h2.length, content).values + +# Extract YAML front matter +MQ.run(MQ::Query.yaml, content).values ``` ## License diff --git a/lib/mq.rb b/lib/mq.rb index 1e1f61a..a31d04d 100644 --- a/lib/mq.rb +++ b/lib/mq.rb @@ -8,6 +8,8 @@ require_relative "mq/mq_ruby" end +require_relative "mq/query" + module MQ class Error < StandardError; end @@ -46,15 +48,17 @@ def to_h end class << self - # Run an mq query on the provided content + # Run an mq query on the provided content. + # Accepts either a query string or a {Query} object. # - # @param code [String] The mq query string + # @param code [String, Query] The mq query string or Query builder object # @param content [String] The markdown/HTML/text content to process # @param options [Options, nil] Optional configuration options # @return [Result] The query results def run(code, content, options = nil) + query = code.respond_to?(:to_query) ? code.to_query : code options_hash = options&.to_h - _run(code, content, options_hash) + _run(query, content, options_hash) end # Convert HTML to Markdown diff --git a/lib/mq/query.rb b/lib/mq/query.rb new file mode 100644 index 0000000..1e6eb15 --- /dev/null +++ b/lib/mq/query.rb @@ -0,0 +1,556 @@ +# frozen_string_literal: true + +module MQ + # Programmatic query builder for constructing mq queries in Ruby. + # + # @example Basic selector + # MQ::Query.h2 + # # => ".h2" + # + # @example Selector with filter + # MQ::Query.h2.select { contains("Feature") } + # # => '.h2 | select(contains("Feature"))' + # + # @example Pipe operator + # MQ::Query.h2 | MQ::Query.to_text + # # => ".h2 | to_text()" + # + # @example Attribute access + # MQ::Query.link.url + # # => ".link | .url" + # + # @example Complex chain + # MQ::Query.h2 + # .select { contains("Section") & starts_with("##") } + # .to_text + # # => '.h2 | select(contains("Section") and starts_with("##")) | to_text()' + # + # @example Using with MQ.run + # result = MQ.run(MQ::Query.h2.select { contains("Feature") }, content) + class Query + def initialize(expr = "") + @expr = expr.to_s + end + + # Pipe two queries together using the | operator. + # + # @param other [Query, #to_query] the query to pipe into + # @return [Query] + def |(other) + self.class.new("#{@expr} | #{other.to_query}") + end + + # Append a select() filter. + # + # @param filter [Filter, String, nil] filter expression (or use block) + # @yield block evaluated in {FilterDSL} context + # @return [Query] + def select(filter = nil, &block) + filter_str = resolve_filter(filter, &block) + pipe_with("select(#{filter_str})") + end + + # Append a map() transformation. + # + # @param filter [Filter, String, nil] filter expression (or use block) + # @yield block evaluated in {FilterDSL} context + # @return [Query] + def map(filter = nil, &block) + filter_str = resolve_filter(filter, &block) + pipe_with("map(#{filter_str})") + end + + def to_text = pipe_with("to_text()") + def to_markdown = pipe_with("to_markdown()") + def to_mdx = pipe_with("to_mdx()") + def to_html = pipe_with("to_html()") + def to_string = pipe_with("to_string()") + def to_number = pipe_with("to_number()") + def to_array = pipe_with("to_array()") + def to_bytes = pipe_with("to_bytes()") + def to_markdown_string = pipe_with("to_markdown_string()") + + def length = pipe_with("length") + def len = pipe_with("len()") + def utf8bytelen = pipe_with("utf8bytelen()") + def add = pipe_with("add") + def first = pipe_with("first") + def last = pipe_with("last") + def empty = pipe_with("empty") + def reverse = pipe_with("reverse") + def sort = pipe_with("sort") + def compact = pipe_with("compact") + def uniq = pipe_with("uniq") + def flatten = pipe_with("flatten") + def keys = pipe_with("keys") + def values = pipe_with("values") + def entries = pipe_with("entries") + def children = pipe_with(".children") + + def split(separator) + pipe_with("split(#{separator.inspect})") + end + + def join(separator) + pipe_with("join(#{separator.inspect})") + end + + def nth(n) + pipe_with("nth(#{n})") + end + + def limit(n) + pipe_with("limit(#{n})") + end + + def range(n) + pipe_with("range(#{n})") + end + + def slice(start, stop) + pipe_with("slice(#{start}, #{stop})") + end + + def index(value) + pipe_with("index(#{value.inspect})") + end + + def rindex(value) + pipe_with("rindex(#{value.inspect})") + end + + def del(value) + pipe_with("del(#{value.inspect})") + end + + def insert(idx, val) + pipe_with("insert(#{idx}, #{val.inspect})") + end + + def repeat(n) + pipe_with("repeat(#{n})") + end + + def trim = pipe_with("trim()") + def ltrim = pipe_with("ltrim()") + def rtrim = pipe_with("rtrim()") + def downcase = pipe_with("downcase()") + def upcase = pipe_with("upcase()") + def ascii_downcase = pipe_with("ascii_downcase()") + def ascii_upcase = pipe_with("ascii_upcase()") + def explode = pipe_with("explode()") + def implode = pipe_with("implode()") + def url_encode = pipe_with("url_encode()") + def intern = pipe_with("intern()") + + def gsub(pattern, replacement) + pipe_with("gsub(#{pattern.inspect}, #{replacement.inspect})") + end + + def replace(from, to) + pipe_with("replace(#{from.inspect}, #{to.inspect})") + end + + def test(pattern) + pipe_with("test(#{pattern.inspect})") + end + + def capture(pattern) + pipe_with("capture(#{pattern.inspect})") + end + + def abs = pipe_with("abs()") + def ceil = pipe_with("ceil()") + def floor = pipe_with("floor()") + def round = pipe_with("round()") + def trunc = pipe_with("trunc()") + def sqrt = pipe_with("sqrt()") + def ln = pipe_with("ln()") + def log10 = pipe_with("log10()") + def exp = pipe_with("exp()") + def negate_val = pipe_with("negate()") + def is_nan = pipe_with("is_nan()") + + def pow(n) + pipe_with("pow(#{n})") + end + + def min(other) + pipe_with("min(#{other})") + end + + def max(other) + pipe_with("max(#{other})") + end + + # --- Type / logic --- + + def type = pipe_with("type") + def debug = pipe_with("debug") + + def coalesce(default) + pipe_with("coalesce(#{default.inspect})") + end + + def base64 = pipe_with("base64()") + def base64d = pipe_with("base64d()") + def base64url = pipe_with("base64url()") + def base64urld = pipe_with("base64urld()") + def md5 = pipe_with("md5()") + def sha256 = pipe_with("sha256()") + def sha512 = pipe_with("sha512()") + def from_hex = pipe_with("from_hex()") + def to_hex = pipe_with("to_hex()") + def to_hex_str = pipe_with("to_hex()") + + def basename = pipe_with("basename()") + def dirname = pipe_with("dirname()") + def extname = pipe_with("extname()") + def stem = pipe_with("stem()") + + def path_join(other) + pipe_with("path_join(#{other.inspect})") + end + + def get(key) + pipe_with("get(#{key.inspect})") + end + + def set(key, val) + pipe_with("set(#{key.inspect}, #{val.inspect})") + end + + # Access a dict property by key (generates ."key" selector) + def property(key) + pipe_with(".\"#{key}\"") + end + + # Attribute selectors (access attributes of selected nodes) + # These generate attribute selector syntax (.url, .lang, etc.) + + def value = pipe_with(".value") + def lang = pipe_with(".lang") + def meta = pipe_with(".meta") + def fence = pipe_with(".fence") + def url = pipe_with(".url") + def alt = pipe_with(".alt") + def title = pipe_with(".title") + def ident = pipe_with(".ident") + def label = pipe_with(".label") + def depth = pipe_with(".depth") + def level = pipe_with(".level") + def item_index = pipe_with(".index") + def ordered = pipe_with(".ordered") + def checked = pipe_with(".checked") + def column = pipe_with(".column") + def row = pipe_with(".row") + def align = pipe_with(".align") + def mdx_name = pipe_with(".name") + + def update(content) + pipe_with("update(#{content.inspect})") + end + + def attr(name) + pipe_with("attr(#{name.inspect})") + end + + def set_attr(name, val) + pipe_with("set_attr(#{name.inspect}, #{val.inspect})") + end + + def get_title = pipe_with("get_title") + def get_url = pipe_with("get_url") + + def set_check(val) + pipe_with("set_check(#{val})") + end + + def set_ref(ref) + pipe_with("set_ref(#{ref.inspect})") + end + + def set_code_block_lang(lang) + pipe_with("set_code_block_lang(#{lang.inspect})") + end + + def set_list_ordered(val) + pipe_with("set_list_ordered(#{val})") + end + + # Convert current value to a code block with the given language. + def to_code(lang = nil) + lang ? pipe_with("to_code(#{lang.inspect})") : pipe_with("to_code(null)") + end + + def to_code_inline = pipe_with("to_code_inline()") + + # Convert current value to a heading of the given depth (1-6). + def to_h(depth) + pipe_with("to_h(#{depth})") + end + + def to_hr = pipe_with("to_hr()") + + # Create a link node. With all three args no auto-prepend occurs. + # With two args the current value becomes the link text. + def to_link(url, text = nil, link_title = "") + if text + pipe_with("to_link(#{url.inspect}, #{text.inspect}, #{link_title.inspect})") + else + pipe_with("to_link(#{url.inspect}, #{link_title.inspect})") + end + end + + # Create an image node. With all three args no auto-prepend occurs. + # With two args the current value becomes the alt text. + def to_image(url, img_alt = nil, img_title = "") + if img_alt + pipe_with("to_image(#{url.inspect}, #{img_alt.inspect}, #{img_title.inspect})") + else + pipe_with("to_image(#{url.inspect}, #{img_title.inspect})") + end + end + + def to_math = pipe_with("to_math()") + def to_math_inline = pipe_with("to_math_inline()") + def to_strong = pipe_with("to_strong()") + def to_em = pipe_with("to_em()") + def to_md_text = pipe_with("to_md_text()") + + # Convert current value to a list item at the given nesting level. + def to_md_list(list_level) + pipe_with("to_md_list(#{list_level})") + end + + # Convert current value to a markdown element with the given node name. + def to_md_name(node_name) + pipe_with("to_md_name(#{node_name.inspect})") + end + + # Build a table row from the given cell values. + def to_md_table_row(*cells) + pipe_with("to_md_table_row(#{cells.map(&:inspect).join(', ')})") + end + + # Build a table cell with content, row index, and column index. + def to_md_table_cell(content, r, c) + pipe_with("to_md_table_cell(#{content.inspect}, #{r}, #{c})") + end + + # Returns the mq query string. + # @return [String] + def to_query = @expr + alias to_s to_query + + class << self + # --- Heading selectors: h1 through h6 --- + (1..6).each { |n| define_method("h#{n}") { new(".h#{n}") } } + + # Generic heading (any level) + def heading = new(".heading") + + # Block element selectors + def code = new(".code") + def paragraph = new(".p") + def blockquote = new(".blockquote") + def hr = new(".hr") + def image = new(".image") + def link = new(".link") + def text = new(".text") + def strong = new(".strong") + def emphasis = new(".emphasis") + def delete = new(".delete") + def math = new(".math") + def table = new(".table") + def table_align = new(".table_align") + def html = new(".html") + def definition = new(".definition") + def footnote = new(".footnote") + def toml = new(".toml") + def yaml = new(".yaml") + + # Inline element selectors + def code_inline = new(".code_inline") + def math_inline = new(".math_inline") + def link_ref = new(".link_ref") + def image_ref = new(".image_ref") + def footnote_ref = new(".footnote_ref") + def line_break = new(".break") + + # Task list selectors + def task = new(".task") + def todo = new(".todo") + def done = new(".done") + + # --- List selector --- + def list = new(".[]") + + # List item at a specific index: .[n] + def list_at(n) + new(".[#{n}]") + end + + # --- Table selectors with row/column indexing --- + + # All cells in a specific row: .[n][] + def table_row(n) + new(".[#{n}][]") + end + + # All cells in a specific column: .[][n] + def table_col(n) + new(".[][#{n}]") + end + + # A specific cell: .[row][col] + def table_cell(r, c) + new(".[#{r}][#{c}]") + end + + # --- MDX selectors --- + def mdx_jsx_flow_element = new(".mdx_jsx_flow_element") + def mdx_text_expression = new(".mdx_text_expression") + def mdx_jsx_text_element = new(".mdx_jsx_text_element") + def mdx_flow_expression = new(".mdx_flow_expression") + def mdx_js_esm = new(".mdx_js_esm") + + # Recursive / deep selector (..) + def recursive = new("..") + + # --- Attribute selectors (as standalone starting points) --- + def value = new(".value") + def node_values = new(".values") + def lang = new(".lang") + def meta = new(".meta") + def fence = new(".fence") + def url = new(".url") + def alt = new(".alt") + def depth = new(".depth") + def level = new(".level") + def ordered = new(".ordered") + def checked = new(".checked") + def column = new(".column") + def row = new(".row") + def align = new(".align") + + # Dict property selector: ."key" + def property(key) + new(".\"#{key}\"") + end + + # Class-level select (no leading selector) + # + # @param filter [Filter, String, nil] + # @yield block evaluated in {FilterDSL} context + # @return [Query] + def select(filter = nil, &block) + filter_str = new.send(:resolve_filter, filter, &block) + new("select(#{filter_str})") + end + + def to_text = new("to_text()") + def to_markdown = new("to_markdown()") + end + + private + + def pipe_with(expr) + @expr.empty? ? self.class.new(expr) : self.class.new("#{@expr} | #{expr}") + end + + def resolve_filter(filter, &block) + if block_given? + Filter.build(&block) + elsif filter.respond_to?(:to_query) + filter.to_query + else + filter.to_s + end + end + end + + # Represents a boolean filter expression for use inside select() and map(). + # + # Filters can be combined with & (and) and | (or): + # contains("foo") & starts_with("bar") + # # => 'contains("foo") and starts_with("bar")' + class Filter + def initialize(expr) + @expr = expr.to_s + end + + # Build a filter expression by evaluating a block in {FilterDSL} context. + # @yield block in FilterDSL context + # @return [String] + def self.build(&block) + result = FilterDSL.new.instance_eval(&block) + result.respond_to?(:to_filter) ? result.to_filter : result.to_s + end + + # Combine two filters with boolean AND. + def &(other) = self.class.new("#{@expr} and #{other}") + + # Combine two filters with boolean OR. + def |(other) = self.class.new("#{@expr} or #{other}") + + def to_filter = @expr + alias to_query to_filter + alias to_s to_filter + end + + # DSL context for building filter expressions inside select/map blocks. + # + # All methods return a {Filter} that can be combined with & and |. + # + # @example String matching + # MQ::Query.h2.select { contains("Feature") & starts_with("##") } + # + # @example Comparison + # MQ::Query.list.select { gt(5) } + # + # @example Negation + # MQ::Query.select { negate(contains("draft")) } + class FilterDSL + # String matching + def contains(text) = Filter.new("contains(#{text.inspect})") + def starts_with(text) = Filter.new("starts_with(#{text.inspect})") + def ends_with(text) = Filter.new("ends_with(#{text.inspect})") + def test(pattern) = Filter.new("test(#{pattern.inspect})") + + # Regex matching + def is_regex_match(pattern) = Filter.new("is_regex_match(#{pattern.inspect})") + def is_not_regex_match(pattern) = Filter.new("is_not_regex_match(#{pattern.inspect})") + + # Comparison operators + # These compare the current pipeline value against the given argument. + def eq(value) = Filter.new("eq(#{value.inspect})") + def ne(value) = Filter.new("ne(#{value.inspect})") + def gt(value) = Filter.new("gt(#{value.inspect})") + def gte(value) = Filter.new("gte(#{value.inspect})") + def lt(value) = Filter.new("lt(#{value.inspect})") + def lte(value) = Filter.new("lte(#{value.inspect})") + + # Type checks + def is_mdx = Filter.new("is_mdx()") + def is_none = Filter.new("is_none()") + def is_nan = Filter.new("is_nan()") + def type = Filter.new("type") + + # String transforms usable in filter context + def length = Filter.new("length") + def ascii_downcase = Filter.new("ascii_downcase()") + def ascii_upcase = Filter.new("ascii_upcase()") + def trim = Filter.new("trim()") + def empty = Filter.new("empty") + def add = Filter.new("add") + + # Negate a filter expression with not(). + # Use +negate+ instead of +not+ since +not+ is a Ruby keyword. + # + # @example + # MQ::Query.select { negate(contains("draft")) } + # # => 'select(not(contains("draft")))' + def negate(filter) = Filter.new("not(#{filter})") + end +end diff --git a/mq-ruby.gemspec b/mq-ruby.gemspec index a1e8924..79d328c 100644 --- a/mq-ruby.gemspec +++ b/mq-ruby.gemspec @@ -2,14 +2,14 @@ Gem::Specification.new do |spec| spec.name = "mq-ruby" - spec.version = "0.1.22" + spec.version = "0.1.23" spec.authors = ["Takahiro Sato"] spec.summary = "Ruby bindings for mq Markdown processing" spec.description = "mq is a jq-like command-line tool for Markdown processing. This gem provides Ruby bindings for mq." spec.homepage = "https://mqlang.org/" spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + spec.required_ruby_version = ">= 3.1" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/harehare/mq-ruby" diff --git a/spec/mq_spec.rb b/spec/mq_spec.rb index df5714c..10d09bb 100644 --- a/spec/mq_spec.rb +++ b/spec/mq_spec.rb @@ -184,4 +184,643 @@ expect(options.use_title_as_h1).to be true end end + + describe MQ::Query do + describe "selector class methods" do + it "builds h1 selector" do + expect(MQ::Query.h1.to_query).to eq(".h1") + end + + it "builds h2 selector" do + expect(MQ::Query.h2.to_query).to eq(".h2") + end + + it "builds h3-h6 selectors" do + (3..6).each do |n| + expect(MQ::Query.public_send("h#{n}").to_query).to eq(".h#{n}") + end + end + + it "builds code selector" do + expect(MQ::Query.code.to_query).to eq(".code") + end + + it "builds list selector" do + expect(MQ::Query.list.to_query).to eq(".[]") + end + + it "builds paragraph selector" do + expect(MQ::Query.paragraph.to_query).to eq(".p") + end + + it "builds blockquote selector" do + expect(MQ::Query.blockquote.to_query).to eq(".blockquote") + end + + it "builds heading selector" do + expect(MQ::Query.heading.to_query).to eq(".heading") + end + + it "builds text selector" do + expect(MQ::Query.text.to_query).to eq(".text") + end + + # New selectors + it "builds strong selector" do expect(MQ::Query.strong.to_query).to eq(".strong") end + it "builds emphasis selector" do expect(MQ::Query.emphasis.to_query).to eq(".emphasis") end + it "builds delete selector" do expect(MQ::Query.delete.to_query).to eq(".delete") end + it "builds math selector" do expect(MQ::Query.math.to_query).to eq(".math") end + it "builds table selector" do expect(MQ::Query.table.to_query).to eq(".table") end + it "builds table_align selector" do expect(MQ::Query.table_align.to_query).to eq(".table_align") end + it "builds html selector" do expect(MQ::Query.html.to_query).to eq(".html") end + it "builds definition selector" do expect(MQ::Query.definition.to_query).to eq(".definition") end + it "builds footnote selector" do expect(MQ::Query.footnote.to_query).to eq(".footnote") end + it "builds toml selector" do expect(MQ::Query.toml.to_query).to eq(".toml") end + it "builds yaml selector" do expect(MQ::Query.yaml.to_query).to eq(".yaml") end + it "builds code_inline selector" do expect(MQ::Query.code_inline.to_query).to eq(".code_inline") end + it "builds math_inline selector" do expect(MQ::Query.math_inline.to_query).to eq(".math_inline") end + it "builds link_ref selector" do expect(MQ::Query.link_ref.to_query).to eq(".link_ref") end + it "builds image_ref selector" do expect(MQ::Query.image_ref.to_query).to eq(".image_ref") end + it "builds footnote_ref selector" do expect(MQ::Query.footnote_ref.to_query).to eq(".footnote_ref") end + it "builds line_break selector" do expect(MQ::Query.line_break.to_query).to eq(".break") end + it "builds task selector" do expect(MQ::Query.task.to_query).to eq(".task") end + it "builds todo selector" do expect(MQ::Query.todo.to_query).to eq(".todo") end + it "builds done selector" do expect(MQ::Query.done.to_query).to eq(".done") end + it "builds recursive selector" do expect(MQ::Query.recursive.to_query).to eq("..") end + + it "builds mdx_jsx_flow_element selector" do + expect(MQ::Query.mdx_jsx_flow_element.to_query).to eq(".mdx_jsx_flow_element") + end + it "builds mdx_text_expression selector" do + expect(MQ::Query.mdx_text_expression.to_query).to eq(".mdx_text_expression") + end + it "builds mdx_jsx_text_element selector" do + expect(MQ::Query.mdx_jsx_text_element.to_query).to eq(".mdx_jsx_text_element") + end + it "builds mdx_flow_expression selector" do + expect(MQ::Query.mdx_flow_expression.to_query).to eq(".mdx_flow_expression") + end + it "builds mdx_js_esm selector" do + expect(MQ::Query.mdx_js_esm.to_query).to eq(".mdx_js_esm") + end + end + + describe "indexed and parameterized selectors" do + it "builds list_at(n) selector" do + expect(MQ::Query.list_at(0).to_query).to eq(".[0]") + expect(MQ::Query.list_at(2).to_query).to eq(".[2]") + end + + it "builds table_row(n) selector" do + expect(MQ::Query.table_row(0).to_query).to eq(".[0][]") + end + + it "builds table_col(n) selector" do + expect(MQ::Query.table_col(1).to_query).to eq(".[][1]") + end + + it "builds table_cell(row, col) selector" do + expect(MQ::Query.table_cell(1, 2).to_query).to eq(".[1][2]") + end + + it "builds property selector" do + expect(MQ::Query.property("title").to_query).to eq('."title"') + end + end + + describe "attribute selectors (class-level)" do + it "builds value attribute selector" do expect(MQ::Query.value.to_query).to eq(".value") end + it "builds lang attribute selector" do expect(MQ::Query.lang.to_query).to eq(".lang") end + it "builds meta attribute selector" do expect(MQ::Query.meta.to_query).to eq(".meta") end + it "builds fence attribute selector" do expect(MQ::Query.fence.to_query).to eq(".fence") end + it "builds url attribute selector" do expect(MQ::Query.url.to_query).to eq(".url") end + it "builds alt attribute selector" do expect(MQ::Query.alt.to_query).to eq(".alt") end + it "builds depth attribute selector" do expect(MQ::Query.depth.to_query).to eq(".depth") end + it "builds level attribute selector" do expect(MQ::Query.level.to_query).to eq(".level") end + it "builds ordered attribute selector" do expect(MQ::Query.ordered.to_query).to eq(".ordered") end + it "builds checked attribute selector" do expect(MQ::Query.checked.to_query).to eq(".checked") end + it "builds column attribute selector" do expect(MQ::Query.column.to_query).to eq(".column") end + it "builds row attribute selector" do expect(MQ::Query.row.to_query).to eq(".row") end + it "builds align attribute selector" do expect(MQ::Query.align.to_query).to eq(".align") end + end + + describe "pipe operator |" do + it "pipes two queries together" do + query = MQ::Query.h2 | MQ::Query.to_text + expect(query.to_query).to eq(".h2 | to_text()") + end + + it "pipes multiple queries" do + query = MQ::Query.h2 | MQ::Query.select { contains("Section") } | MQ::Query.to_text + expect(query.to_query).to eq('.h2 | select(contains("Section")) | to_text()') + end + end + + describe "#select with block" do + it "appends select with contains filter" do + query = MQ::Query.h2.select { contains("Feature") } + expect(query.to_query).to eq('.h2 | select(contains("Feature"))') + end + + it "appends select with starts_with filter" do + query = MQ::Query.h2.select { starts_with("##") } + expect(query.to_query).to eq('.h2 | select(starts_with("##"))') + end + + it "appends select with is_mdx filter" do + query = MQ::Query.select { is_mdx } + expect(query.to_query).to eq("select(is_mdx())") + end + + it "combines filters with AND (&)" do + query = MQ::Query.h2.select { contains("Feature") & starts_with("##") } + expect(query.to_query).to eq('.h2 | select(contains("Feature") and starts_with("##"))') + end + + it "combines filters with OR (|)" do + query = MQ::Query.h2.select { contains("A") | contains("B") } + expect(query.to_query).to eq('.h2 | select(contains("A") or contains("B"))') + end + + it "negates a filter with negate()" do + query = MQ::Query.select { negate(contains("draft")) } + expect(query.to_query).to eq('select(not(contains("draft")))') + end + end + + describe "#select with string argument" do + it "accepts a raw string filter" do + query = MQ::Query.h2.select('contains("Feature")') + expect(query.to_query).to eq('.h2 | select(contains("Feature"))') + end + end + + describe "#select with Filter argument" do + it "accepts a Filter object" do + filter = MQ::Filter.new('contains("Feature")') + query = MQ::Query.h2.select(filter) + expect(query.to_query).to eq('.h2 | select(contains("Feature"))') + end + end + + describe "output transformation methods" do + it "chains to_text" do expect(MQ::Query.h2.to_text.to_query).to eq(".h2 | to_text()") end + it "chains to_markdown" do expect(MQ::Query.h2.to_markdown.to_query).to eq(".h2 | to_markdown()") end + it "chains to_mdx" do expect(MQ::Query.text.to_mdx.to_query).to eq(".text | to_mdx()") end + it "chains to_html" do expect(MQ::Query.text.to_html.to_query).to eq(".text | to_html()") end + it "chains to_string" do expect(MQ::Query.text.to_string.to_query).to eq(".text | to_string()") end + it "chains to_number" do expect(MQ::Query.text.to_number.to_query).to eq(".text | to_number()") end + it "chains to_array" do expect(MQ::Query.text.to_array.to_query).to eq(".text | to_array()") end + it "chains to_markdown_string" do expect(MQ::Query.text.to_markdown_string.to_query).to eq(".text | to_markdown_string()") end + end + + describe "string transformation methods" do + it "chains trim" do expect(MQ::Query.text.trim.to_query).to eq(".text | trim()") end + it "chains ltrim" do expect(MQ::Query.text.ltrim.to_query).to eq(".text | ltrim()") end + it "chains rtrim" do expect(MQ::Query.text.rtrim.to_query).to eq(".text | rtrim()") end + it "chains downcase" do expect(MQ::Query.text.downcase.to_query).to eq(".text | downcase()") end + it "chains upcase" do expect(MQ::Query.text.upcase.to_query).to eq(".text | upcase()") end + it "chains ascii_downcase" do expect(MQ::Query.text.ascii_downcase.to_query).to eq(".text | ascii_downcase()") end + it "chains ascii_upcase" do expect(MQ::Query.text.ascii_upcase.to_query).to eq(".text | ascii_upcase()") end + it "chains explode" do expect(MQ::Query.text.explode.to_query).to eq(".text | explode()") end + it "chains implode" do expect(MQ::Query.text.implode.to_query).to eq(".text | implode()") end + it "chains url_encode" do expect(MQ::Query.text.url_encode.to_query).to eq(".text | url_encode()") end + it "chains intern" do expect(MQ::Query.text.intern.to_query).to eq(".text | intern()") end + it "chains len" do expect(MQ::Query.text.len.to_query).to eq(".text | len()") end + it "chains utf8bytelen" do expect(MQ::Query.text.utf8bytelen.to_query).to eq(".text | utf8bytelen()") end + + it "chains gsub with pattern and replacement" do + expect(MQ::Query.text.gsub("foo", "bar").to_query).to eq('.text | gsub("foo", "bar")') + end + + it "chains replace with from and to" do + expect(MQ::Query.text.replace("old", "new").to_query).to eq('.text | replace("old", "new")') + end + + it "chains test with regex pattern" do + expect(MQ::Query.text.test("\\d+").to_query).to eq('.text | test("\\\\d+")') + end + + it "chains capture with regex pattern" do + expect(MQ::Query.text.capture("(\\w+)").to_query).to eq('.text | capture("(\\\\w+)")') + end + + it "chains split" do + expect(MQ::Query.text.split(",").to_query).to eq('.text | split(",")') + end + + it "chains repeat" do + expect(MQ::Query.text.repeat(3).to_query).to eq(".text | repeat(3)") + end + + it "chains slice" do + expect(MQ::Query.text.slice(0, 5).to_query).to eq(".text | slice(0, 5)") + end + + it "chains index" do + expect(MQ::Query.text.index("foo").to_query).to eq('.text | index("foo")') + end + + it "chains rindex" do + expect(MQ::Query.text.rindex("foo").to_query).to eq('.text | rindex("foo")') + end + end + + describe "collection/array methods" do + it "chains length" do expect(MQ::Query.list.length.to_query).to eq(".[] | length") end + it "chains add" do expect(MQ::Query.list.add.to_query).to eq(".[] | add") end + it "chains first" do expect(MQ::Query.list.first.to_query).to eq(".[] | first") end + it "chains last" do expect(MQ::Query.list.last.to_query).to eq(".[] | last") end + it "chains empty" do expect(MQ::Query.list.empty.to_query).to eq(".[] | empty") end + it "chains reverse" do expect(MQ::Query.list.reverse.to_query).to eq(".[] | reverse") end + it "chains sort" do expect(MQ::Query.list.sort.to_query).to eq(".[] | sort") end + it "chains compact" do expect(MQ::Query.list.compact.to_query).to eq(".[] | compact") end + it "chains uniq" do expect(MQ::Query.list.uniq.to_query).to eq(".[] | uniq") end + it "chains flatten" do expect(MQ::Query.list.flatten.to_query).to eq(".[] | flatten") end + it "chains keys" do expect(MQ::Query.list.keys.to_query).to eq(".[] | keys") end + it "chains values" do expect(MQ::Query.list.values.to_query).to eq(".[] | values") end + it "chains entries" do expect(MQ::Query.list.entries.to_query).to eq(".[] | entries") end + it "chains children" do expect(MQ::Query.list.children.to_query).to eq(".[] | .children") end + + it "chains nth" do expect(MQ::Query.h2.nth(2).to_query).to eq(".h2 | nth(2)") end + it "chains limit" do expect(MQ::Query.h2.limit(5).to_query).to eq(".h2 | limit(5)") end + it "chains range" do expect(MQ::Query.h2.range(3).to_query).to eq(".h2 | range(3)") end + + it "chains join" do + expect(MQ::Query.list.join(", ").to_query).to eq('.[] | join(", ")') + end + + it "chains del" do + expect(MQ::Query.list.del("item").to_query).to eq('.[] | del("item")') + end + + it "chains insert" do + expect(MQ::Query.list.insert(0, "new").to_query).to eq('.[] | insert(0, "new")') + end + end + + describe "math methods" do + it "chains abs" do expect(MQ::Query.text.abs.to_query).to eq(".text | abs()") end + it "chains ceil" do expect(MQ::Query.text.ceil.to_query).to eq(".text | ceil()") end + it "chains floor" do expect(MQ::Query.text.floor.to_query).to eq(".text | floor()") end + it "chains round" do expect(MQ::Query.text.round.to_query).to eq(".text | round()") end + it "chains trunc" do expect(MQ::Query.text.trunc.to_query).to eq(".text | trunc()") end + it "chains sqrt" do expect(MQ::Query.text.sqrt.to_query).to eq(".text | sqrt()") end + it "chains ln" do expect(MQ::Query.text.ln.to_query).to eq(".text | ln()") end + it "chains log10" do expect(MQ::Query.text.log10.to_query).to eq(".text | log10()") end + it "chains exp" do expect(MQ::Query.text.exp.to_query).to eq(".text | exp()") end + it "chains negate_val" do expect(MQ::Query.text.negate_val.to_query).to eq(".text | negate()") end + it "chains is_nan" do expect(MQ::Query.text.is_nan.to_query).to eq(".text | is_nan()") end + + it "chains pow" do + expect(MQ::Query.text.pow(2).to_query).to eq(".text | pow(2)") + end + + it "chains min" do + expect(MQ::Query.text.min(0).to_query).to eq(".text | min(0)") + end + + it "chains max" do + expect(MQ::Query.text.max(100).to_query).to eq(".text | max(100)") + end + end + + describe "type / logic methods" do + it "chains type" do expect(MQ::Query.text.type.to_query).to eq(".text | type") end + it "chains debug" do expect(MQ::Query.text.debug.to_query).to eq(".text | debug") end + + it "chains coalesce with default value" do + expect(MQ::Query.text.coalesce("default").to_query).to eq('.text | coalesce("default")') + end + end + + describe "encoding methods" do + it "chains base64" do expect(MQ::Query.text.base64.to_query).to eq(".text | base64()") end + it "chains base64d" do expect(MQ::Query.text.base64d.to_query).to eq(".text | base64d()") end + it "chains base64url" do expect(MQ::Query.text.base64url.to_query).to eq(".text | base64url()") end + it "chains base64urld" do expect(MQ::Query.text.base64urld.to_query).to eq(".text | base64urld()") end + it "chains md5" do expect(MQ::Query.text.md5.to_query).to eq(".text | md5()") end + it "chains sha256" do expect(MQ::Query.text.sha256.to_query).to eq(".text | sha256()") end + it "chains sha512" do expect(MQ::Query.text.sha512.to_query).to eq(".text | sha512()") end + it "chains from_hex" do expect(MQ::Query.text.from_hex.to_query).to eq(".text | from_hex()") end + it "chains to_hex" do expect(MQ::Query.text.to_hex.to_query).to eq(".text | to_hex()") end + end + + describe "path methods" do + it "chains basename" do expect(MQ::Query.text.basename.to_query).to eq(".text | basename()") end + it "chains dirname" do expect(MQ::Query.text.dirname.to_query).to eq(".text | dirname()") end + it "chains extname" do expect(MQ::Query.text.extname.to_query).to eq(".text | extname()") end + it "chains stem" do expect(MQ::Query.text.stem.to_query).to eq(".text | stem()") end + + it "chains path_join" do + expect(MQ::Query.text.path_join("file.md").to_query).to eq('.text | path_join("file.md")') + end + end + + describe "dict methods" do + it "chains get" do + expect(MQ::Query.text.get("key").to_query).to eq('.text | get("key")') + end + + it "chains set" do + expect(MQ::Query.text.set("key", "val").to_query).to eq('.text | set("key", "val")') + end + + it "chains property" do + expect(MQ::Query.text.property("title").to_query).to eq('.text | ."title"') + end + end + + describe "attribute selector methods (instance)" do + it "chains value" do expect(MQ::Query.link.value.to_query).to eq(".link | .value") end + it "chains lang" do expect(MQ::Query.code.lang.to_query).to eq(".code | .lang") end + it "chains meta" do expect(MQ::Query.code.meta.to_query).to eq(".code | .meta") end + it "chains fence" do expect(MQ::Query.code.fence.to_query).to eq(".code | .fence") end + it "chains url" do expect(MQ::Query.link.url.to_query).to eq(".link | .url") end + it "chains alt" do expect(MQ::Query.image.alt.to_query).to eq(".image | .alt") end + it "chains title" do expect(MQ::Query.link.title.to_query).to eq(".link | .title") end + it "chains ident" do expect(MQ::Query.link_ref.ident.to_query).to eq(".link_ref | .ident") end + it "chains label" do expect(MQ::Query.link_ref.label.to_query).to eq(".link_ref | .label") end + it "chains depth" do expect(MQ::Query.heading.depth.to_query).to eq(".heading | .depth") end + it "chains level" do expect(MQ::Query.heading.level.to_query).to eq(".heading | .level") end + it "chains item_index" do expect(MQ::Query.list.item_index.to_query).to eq(".[] | .index") end + it "chains ordered" do expect(MQ::Query.list.ordered.to_query).to eq(".[] | .ordered") end + it "chains checked" do expect(MQ::Query.task.checked.to_query).to eq(".task | .checked") end + it "chains column" do expect(MQ::Query.table.column.to_query).to eq(".table | .column") end + it "chains row" do expect(MQ::Query.table.row.to_query).to eq(".table | .row") end + it "chains align" do expect(MQ::Query.table_align.align.to_query).to eq(".table_align | .align") end + it "chains mdx_name" do expect(MQ::Query.mdx_jsx_flow_element.mdx_name.to_query).to eq(".mdx_jsx_flow_element | .name") end + end + + describe "markdown attribute mutation methods" do + it "chains update" do + expect(MQ::Query.h2.update("New Title").to_query).to eq('.h2 | update("New Title")') + end + + it "chains attr" do + expect(MQ::Query.code.attr("lang").to_query).to eq('.code | attr("lang")') + end + + it "chains set_attr" do + expect(MQ::Query.code.set_attr("lang", "ruby").to_query).to eq('.code | set_attr("lang", "ruby")') + end + + it "chains get_title" do + expect(MQ::Query.link.get_title.to_query).to eq(".link | get_title") + end + + it "chains get_url" do + expect(MQ::Query.link.get_url.to_query).to eq(".link | get_url") + end + + it "chains set_check" do + expect(MQ::Query.task.set_check(true).to_query).to eq(".task | set_check(true)") + end + + it "chains set_ref" do + expect(MQ::Query.link_ref.set_ref("myref").to_query).to eq('.link_ref | set_ref("myref")') + end + + it "chains set_code_block_lang" do + expect(MQ::Query.code.set_code_block_lang("ruby").to_query).to eq('.code | set_code_block_lang("ruby")') + end + + it "chains set_list_ordered" do + expect(MQ::Query.list.set_list_ordered(true).to_query).to eq(".[] | set_list_ordered(true)") + end + end + + describe "markdown construction methods" do + it "chains to_code with language" do + expect(MQ::Query.text.to_code("ruby").to_query).to eq('.text | to_code("ruby")') + end + + it "chains to_code without language" do + expect(MQ::Query.text.to_code.to_query).to eq(".text | to_code(null)") + end + + it "chains to_code_inline" do + expect(MQ::Query.text.to_code_inline.to_query).to eq(".text | to_code_inline()") + end + + it "chains to_h with depth" do + expect(MQ::Query.text.to_h(2).to_query).to eq(".text | to_h(2)") + end + + it "chains to_hr" do + expect(MQ::Query.text.to_hr.to_query).to eq(".text | to_hr()") + end + + it "chains to_link with url, text, and title" do + expect(MQ::Query.text.to_link("https://example.com", "Example", "title").to_query).to eq( + '.text | to_link("https://example.com", "Example", "title")' + ) + end + + it "chains to_link with url and empty title" do + expect(MQ::Query.text.to_link("https://example.com", "Example").to_query).to eq( + '.text | to_link("https://example.com", "Example", "")' + ) + end + + it "chains to_link with url only (current value as text)" do + expect(MQ::Query.text.to_link("https://example.com").to_query).to eq( + '.text | to_link("https://example.com", "")' + ) + end + + it "chains to_image with url, alt, and title" do + expect(MQ::Query.text.to_image("img.png", "alt text", "title").to_query).to eq( + '.text | to_image("img.png", "alt text", "title")' + ) + end + + it "chains to_image with url and alt" do + expect(MQ::Query.text.to_image("img.png", "alt text").to_query).to eq( + '.text | to_image("img.png", "alt text", "")' + ) + end + + it "chains to_math" do expect(MQ::Query.text.to_math.to_query).to eq(".text | to_math()") end + it "chains to_math_inline" do expect(MQ::Query.text.to_math_inline.to_query).to eq(".text | to_math_inline()") end + it "chains to_strong" do expect(MQ::Query.text.to_strong.to_query).to eq(".text | to_strong()") end + it "chains to_em" do expect(MQ::Query.text.to_em.to_query).to eq(".text | to_em()") end + it "chains to_md_text" do expect(MQ::Query.text.to_md_text.to_query).to eq(".text | to_md_text()") end + + it "chains to_md_list with level" do + expect(MQ::Query.text.to_md_list(0).to_query).to eq(".text | to_md_list(0)") + end + + it "chains to_md_name" do + expect(MQ::Query.text.to_md_name("component").to_query).to eq('.text | to_md_name("component")') + end + + it "chains to_md_table_row with cells" do + expect(MQ::Query.text.to_md_table_row("A", "B", "C").to_query).to eq('.text | to_md_table_row("A", "B", "C")') + end + + it "chains to_md_table_cell" do + expect(MQ::Query.text.to_md_table_cell("content", 0, 1).to_query).to eq('.text | to_md_table_cell("content", 0, 1)') + end + end + + describe "chaining multiple operations" do + it "chains selector, select, and to_text" do + query = MQ::Query.h2 + .select { contains("Section") } + .to_text + expect(query.to_query).to eq('.h2 | select(contains("Section")) | to_text()') + end + + it "chains code selector with lang attribute" do + expect(MQ::Query.code.lang.to_query).to eq(".code | .lang") + end + + it "chains link selector with url attribute" do + expect(MQ::Query.link.url.to_query).to eq(".link | .url") + end + + it "builds complex query with multiple steps" do + query = MQ::Query.h2 + .select { contains("API") & negate(contains("Internal")) } + .to_text + .downcase + expect(query.to_query).to eq('.h2 | select(contains("API") and not(contains("Internal"))) | to_text() | downcase()') + end + end + + describe "integration with MQ.run" do + let(:content) { "# Main\n\n## Features\n\n## Installation\n\n## Contributing" } + + it "accepts a Query object in MQ.run" do + result = MQ.run(MQ::Query.h2, content) + expect(result.values).to eq(["## Features", "## Installation", "## Contributing"]) + end + + it "filters with select via Query object" do + result = MQ.run(MQ::Query.h2.select { contains("Feature") }, content) + expect(result.values).to eq(["## Features"]) + end + + it "still accepts plain strings in MQ.run" do + result = MQ.run(".h2", content) + expect(result.values).to eq(["## Features", "## Installation", "## Contributing"]) + end + + it "extracts code block language via attribute selector" do + md = "# Code\n\n```ruby\nputs 'hello'\n```" + result = MQ.run(MQ::Query.code.lang, md) + expect(result.values).to eq(["ruby"]) + end + + it "extracts link URLs via attribute selector" do + md = "# Links\n\n[Google](https://google.com)\n\n[GitHub](https://github.com)" + result = MQ.run(MQ::Query.link.url, md) + expect(result.values).to eq(["https://google.com", "https://github.com"]) + end + + it "applies downcase transformation" do + md = "# Hello World" + result = MQ.run(MQ::Query.h1.to_text.downcase, md) + # to_text() strips heading marks, returning plain text + expect(result.values).to eq(["hello world"]) + end + + it "filters with comparison operator" do + md = "# Section A\n\n## Section B\n\n### Topic C" + result = MQ.run(MQ::Query.h2.select { contains("Section B") }, md) + expect(result.values).to eq(["## Section B"]) + end + + it "filters with ends_with" do + md = "# Section A\n\n## Section B\n\n### Topic C" + result = MQ.run(MQ::Query.heading.select { ends_with("B") }, md) + expect(result.values).to eq(["## Section B"]) + end + + it "filters with is_none check" do + md = "# Title\n\n```ruby\ncode\n```" + result = MQ.run(MQ::Query.code.lang.select { negate(is_none) }, md) + expect(result.values).to eq(["ruby"]) + end + end + end + + describe MQ::Filter do + it "builds a filter from a string" do + filter = MQ::Filter.new('contains("foo")') + expect(filter.to_filter).to eq('contains("foo")') + end + + it "combines filters with AND" do + a = MQ::Filter.new('contains("foo")') + b = MQ::Filter.new('starts_with("bar")') + expect((a & b).to_filter).to eq('contains("foo") and starts_with("bar")') + end + + it "combines filters with OR" do + a = MQ::Filter.new('contains("foo")') + b = MQ::Filter.new('contains("bar")') + expect((a | b).to_filter).to eq('contains("foo") or contains("bar")') + end + end + + describe MQ::FilterDSL do + subject(:dsl) { MQ::FilterDSL.new } + + describe "string matching" do + it "contains" do expect(dsl.contains("foo").to_filter).to eq('contains("foo")') end + it "starts_with" do expect(dsl.starts_with("foo").to_filter).to eq('starts_with("foo")') end + it "ends_with" do expect(dsl.ends_with("foo").to_filter).to eq('ends_with("foo")') end + it "test" do expect(dsl.test("\\d+").to_filter).to eq('test("\\\\d+")') end + end + + describe "regex matching" do + it "is_regex_match" do + expect(dsl.is_regex_match("\\d+").to_filter).to eq('is_regex_match("\\\\d+")') + end + it "is_not_regex_match" do + expect(dsl.is_not_regex_match("\\d+").to_filter).to eq('is_not_regex_match("\\\\d+")') + end + end + + describe "comparison operators" do + it "eq" do expect(dsl.eq("foo").to_filter).to eq('eq("foo")') end + it "ne" do expect(dsl.ne("foo").to_filter).to eq('ne("foo")') end + it "gt" do expect(dsl.gt(5).to_filter).to eq("gt(5)") end + it "gte" do expect(dsl.gte(5).to_filter).to eq("gte(5)") end + it "lt" do expect(dsl.lt(5).to_filter).to eq("lt(5)") end + it "lte" do expect(dsl.lte(5).to_filter).to eq("lte(5)") end + end + + describe "type checks" do + it "is_mdx" do expect(dsl.is_mdx.to_filter).to eq("is_mdx()") end + it "is_none" do expect(dsl.is_none.to_filter).to eq("is_none()") end + it "is_nan" do expect(dsl.is_nan.to_filter).to eq("is_nan()") end + it "type" do expect(dsl.type.to_filter).to eq("type") end + end + + describe "negation" do + it "negate wraps filter with not()" do + f = dsl.contains("draft") + expect(dsl.negate(f).to_filter).to eq('not(contains("draft"))') + end + end + + describe "complex filter combinations" do + it "combines comparison and string filters" do + query = MQ::Query.h2.select { ne("## Draft") & contains("Feature") } + expect(query.to_query).to eq('.h2 | select(ne("## Draft") and contains("Feature"))') + end + + it "triple-combines with AND" do + query = MQ::Query.h2.select { + contains("API") & negate(contains("Internal")) & starts_with("## ") + } + expect(query.to_query).to eq( + '.h2 | select(contains("API") and not(contains("Internal")) and starts_with("## "))' + ) + end + end + end end From e6098ee0e4f6dfaea8aa251111522692c460cbef Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 22:47:02 +0900 Subject: [PATCH 2/9] ci: use latest bundler to fix Pathname::SEPARATOR_PAT error on Ruby HEAD --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ed6ac..aace58a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: ${{ matrix.ruby }} + bundler: latest bundler-cache: true - name: Install system dependencies (Ubuntu) if: runner.os == 'Linux' From 4f773c9a87a45030f98666b0a2ec78713e74db4b Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 23:03:34 +0900 Subject: [PATCH 3/9] ci: update Ruby HEAD setup to use manual gem/bundle steps --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aace58a..9f43e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,12 @@ jobs: uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: ${{ matrix.ruby }} - bundler: latest - bundler-cache: true + bundler-cache: false + - name: Update RubyGems for Ruby HEAD compatibility + if: matrix.ruby == 'head' + run: gem update --system --no-document + - name: Bundle install + run: bundle install --jobs 4 --retry 3 - name: Install system dependencies (Ubuntu) if: runner.os == 'Linux' run: | From 0bbbd4f4d579c2e00e003e2e8d057c85233870cd Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 23:09:21 +0900 Subject: [PATCH 4/9] ci: install latest bundler after gem update for Ruby HEAD compatibility --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f43e76..bc4f235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: bundler-cache: false - name: Update RubyGems for Ruby HEAD compatibility if: matrix.ruby == 'head' - run: gem update --system --no-document + run: | + gem update --system --no-document + gem install bundler --no-document - name: Bundle install run: bundle install --jobs 4 --retry 3 - name: Install system dependencies (Ubuntu) From 7df04dae327d71210644e82d2799bea64706256b Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 23:23:46 +0900 Subject: [PATCH 5/9] ci: add Ruby 4.0 support and revert manual bundler workarounds --- .github/workflows/ci.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc4f235..341e656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - ruby: ['3.3', '3.4'] + ruby: ['3.3', '3.4', '4.0'] include: - os: ubuntu-latest ruby: head @@ -34,14 +34,7 @@ jobs: uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: ${{ matrix.ruby }} - bundler-cache: false - - name: Update RubyGems for Ruby HEAD compatibility - if: matrix.ruby == 'head' - run: | - gem update --system --no-document - gem install bundler --no-document - - name: Bundle install - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Install system dependencies (Ubuntu) if: runner.os == 'Linux' run: | From 32ae042b0fff7de6de2d9e2acecb22396cb814a2 Mon Sep 17 00:00:00 2001 From: harehare Date: Mon, 8 Jun 2026 23:59:21 +0900 Subject: [PATCH 6/9] fix: remove version-specific extension loading to prevent double-load on Ruby 4 and HEAD --- .github/workflows/ci.yml | 11 ++++------- lib/mq.rb | 8 +------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 341e656..b8059b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,16 +8,14 @@ on: jobs: test: - name: Test (Ruby ${{ matrix.ruby }} on ${{ matrix.os }}) - runs-on: ${{ matrix.os }} + name: Test (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] ruby: ['3.3', '3.4', '4.0'] include: - - os: ubuntu-latest - ruby: head + - ruby: head experimental: true continue-on-error: ${{ matrix.experimental || false }} @@ -35,8 +33,7 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Install system dependencies (Ubuntu) - if: runner.os == 'Linux' + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libclang-dev diff --git a/lib/mq.rb b/lib/mq.rb index a31d04d..e70aa97 100644 --- a/lib/mq.rb +++ b/lib/mq.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true -begin - # Try to load the compiled extension - RUBY_VERSION =~ /(\d+\.\d+)/ - require_relative "mq/#{Regexp.last_match(1)}/mq_ruby" -rescue LoadError - require_relative "mq/mq_ruby" -end +require_relative "mq/mq_ruby" require_relative "mq/query" From f4732455ee057f647e8ef8555d61e9ebcdaa18fb Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 9 Jun 2026 22:36:16 +0900 Subject: [PATCH 7/9] ci: scope rust cache by ruby version to prevent binding reuse across versions --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8059b4..d7bc686 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: toolchain: stable - name: Cache Rust dependencies uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + prefix-key: ruby-${{ matrix.ruby }} - name: Set up Ruby uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: From f2bd0cc02e065b0d632f481bbd66ea478eae7775 Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 9 Jun 2026 22:43:48 +0900 Subject: [PATCH 8/9] ci: use resolved ruby version in rust cache key to fix HEAD cache staleness --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7bc686..5e46e47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,15 +26,16 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - prefix-key: ruby-${{ matrix.ruby }} - name: Set up Ruby + id: setup-ruby uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + prefix-key: ruby-${{ steps.setup-ruby.outputs.ruby-version }} - name: Install system dependencies run: | sudo apt-get update From 41483e047b0fbb7e59c9585879de9ae076bff47d Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 9 Jun 2026 22:48:35 +0900 Subject: [PATCH 9/9] ci: remove ruby HEAD from test matrix --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e46e47..9acfceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,6 @@ jobs: fail-fast: false matrix: ruby: ['3.3', '3.4', '4.0'] - include: - - ruby: head - experimental: true continue-on-error: ${{ matrix.experimental || false }}