diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..dc8c434 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,219 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + # Disabled: false positive with Elixir 1.20 — modules do have @moduledoc + # {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.StructFieldAmount, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d66657f..555465e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: ['1.17', '1.19'] + elixir: ['1.17', '1.19', '1.20'] otp: ['27'] steps: diff --git a/README.md b/README.md index 3d288da..5d9d90e 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ Elixir bindings for [mq](https://mqlang.org/), a jq-like command-line tool for M - Process markdown, MDX, HTML, and plain text - Full mq query language support +- Programmatic query builder with Elixir pipe operator - Multiple input and output format options - Configurable rendering options - Fast Rust-powered NIF implementation ## Installation -Add `mq` to your list of dependencies in `mix.exs`: +Add `mq_elixir` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -22,17 +23,101 @@ def deps do end ``` - ## Usage -### Basic Query +### Raw Query String ```elixir # Extract all H1 headings {:ok, result} = Mq.run(".h1", "# Hello\n## World") IO.inspect(result.values) # ["# Hello"] + +# Filter with select +{:ok, result} = Mq.run(".h2 | select(contains(\"Feature\"))", content) +``` + +### Query Builder + +Build queries programmatically using `Mq` functions and the `|>` pipe operator. + +```elixir +# Selectors and transformations chain naturally +{:ok, result} = + Mq.h2() + |> Mq.select(Mq.Filter.contains("Feature")) + |> Mq.to_text() + |> Mq.run(content) +``` + +#### Selectors + +```elixir +Mq.h1() # .h1 +Mq.h2() # .h2 +Mq.code() # .code +Mq.link() # .link +Mq.list() # .[] +Mq.list_at(0) # .[0] +Mq.paragraph() # .p +Mq.task() # .task +Mq.todo() # .todo +Mq.done() # .done +# ... and more (heading, image, blockquote, table, etc.) +``` + +#### Attribute Access + +Attribute selectors work as both standalone queries and as chained operations: + +```elixir +# Standalone +Mq.url() # ".url" +Mq.lang() # ".lang" + +# Chained — access attributes of selected nodes +Mq.link() |> Mq.url() # ".link | .url" +Mq.code() |> Mq.lang() # ".code | .lang" +``` + +#### Filters + +`Mq.Filter` provides composable filter expressions for `select` and `map`: + +```elixir +alias Mq.Filter + +# Basic filters +Filter.contains("Feature") +Filter.starts_with("##") +Filter.ends_with("Guide") +Filter.eq("value") +Filter.gt(5) + +# Combine with |> +Filter.contains("API") +|> Filter.and_filter(Filter.negate(Filter.contains("Internal"))) + +# Combine a list +Filter.all([Filter.contains("A"), Filter.contains("B"), Filter.ne("## Draft")]) +Filter.any([Filter.contains("Alpha"), Filter.contains("Beta")]) + +# Negate +Filter.negate(Filter.contains("draft")) ``` +#### Chaining Operations + +```elixir +Mq.h2() +|> Mq.select(Mq.Filter.contains("API")) +|> Mq.to_text() +|> Mq.downcase() +|> Mq.run(content) +``` + +Available transforms include: `to_text`, `to_markdown`, `to_html`, `downcase`, `upcase`, +`trim`, `split`, `join`, `limit`, `nth`, `reverse`, `sort`, `uniq`, and many more. + ### Working with Results ```elixir @@ -44,6 +129,28 @@ result.text # "# H1\n## H2\n### H3" # Enumerate Enum.each(result, fn heading -> IO.puts(heading) end) + +# Convert to string +to_string(result) # "# H1\n## H2\n### H3" +``` + +### Input Formats + +```elixir +options = %Mq.Options{input_format: :text} +{:ok, result} = Mq.run("select(contains(\"needle\"))", content, options) +``` + +Supported formats: `:markdown` (default), `:mdx`, `:html`, `:text`, `:raw`, `:null` + +### HTML to Markdown + +```elixir +{:ok, markdown} = Mq.html_to_markdown("

Hello

World

") +# => "# Hello\n\nWorld" + +opts = %Mq.ConversionOptions{use_title_as_h1: true} +{:ok, markdown} = Mq.html_to_markdown(html, opts) ``` ## Documentation diff --git a/lib/mq.ex b/lib/mq.ex index 37f9705..b43a9eb 100644 --- a/lib/mq.ex +++ b/lib/mq.ex @@ -21,10 +21,17 @@ defmodule Mq do ## Usage - # Basic heading extraction + # Raw query string {:ok, result} = Mq.run(".h1", "# Hello\\n## World") IO.inspect(result.values) # ["# Hello"] + # Query builder — use Mq directly as the entry point + {:ok, result} = + Mq.h2() + |> Mq.select(Mq.Filter.contains("Feature")) + |> Mq.to_text() + |> Mq.run(content) + # With options options = %Mq.Options{input_format: :markdown} {:ok, result} = Mq.run(".h2", markdown_content, options) @@ -33,14 +40,17 @@ defmodule Mq do {:ok, markdown} = Mq.html_to_markdown("

Hello

") """ - alias Mq.{ConversionOptions, Native, Options, Result} + alias Mq.{ConversionOptions, Native, Options, Query, Result} @doc """ Run an mq query on the provided content. + Accepts either a raw query string or an `Mq.Query` struct built with the + query builder functions on this module. + ## Parameters - - `code` - The mq query string + - `code` - The mq query string or an `%Mq.Query{}` struct - `content` - The markdown/HTML/text content to process - `options` - Optional configuration (defaults to `%Mq.Options{}`) @@ -57,13 +67,23 @@ defmodule Mq do iex> options = %Mq.Options{input_format: :text} iex> {:ok, _result} = Mq.run("select(contains(\\"test\\"))", "line1\\ntest line\\nline3", options) {:ok, %Mq.Result{values: ["test line"], text: "test line"}} + + iex> {:ok, result} = Mq.h2() |> Mq.select(Mq.Filter.contains("World")) |> Mq.run("# Hello\\n## World\\n## Other") + iex> result.values + ["## World"] """ - @spec run(String.t(), String.t(), Options.t() | nil) :: + @spec run(String.t() | Query.t(), String.t(), Options.t() | nil) :: {:ok, Result.t()} | {:error, String.t()} def run(code, content, options \\ nil) do + query_string = + case code do + %Query{} -> Query.to_query_string(code) + str when is_binary(str) -> str + end + opts = options || %Options{} - case Native.run(code, content, Options.to_map(opts)) do + case Native.run(query_string, content, Options.to_map(opts)) do {:ok, result_map} -> {:ok, Result.from_map(result_map)} {:error, _} = error -> error end @@ -102,4 +122,255 @@ defmodule Mq do {:error, _} = error -> error end end + + # Query builder — delegated from Mq.Query + # + # All `Mq.Query` functions are available directly on `Mq`, so you can write: + # + # Mq.h2() + # |> Mq.select(Mq.Filter.contains("Feature")) + # |> Mq.to_text() + # |> Mq.run(content) + # + # See `Mq.Query` for full documentation on each function. + + # Heading selectors + defdelegate h1(), to: Query + defdelegate h2(), to: Query + defdelegate h3(), to: Query + defdelegate h4(), to: Query + defdelegate h5(), to: Query + defdelegate h6(), to: Query + defdelegate heading(), to: Query + + # Block element selectors + defdelegate code(), to: Query + defdelegate paragraph(), to: Query + defdelegate blockquote(), to: Query + defdelegate hr(), to: Query + defdelegate image(), to: Query + defdelegate link(), to: Query + defdelegate text(), to: Query + defdelegate strong(), to: Query + defdelegate emphasis(), to: Query + defdelegate delete(), to: Query + defdelegate math(), to: Query + defdelegate table(), to: Query + defdelegate table_align(), to: Query + defdelegate html(), to: Query + defdelegate definition(), to: Query + defdelegate footnote(), to: Query + defdelegate toml(), to: Query + defdelegate yaml(), to: Query + + # Inline element selectors + defdelegate code_inline(), to: Query + defdelegate math_inline(), to: Query + defdelegate link_ref(), to: Query + defdelegate image_ref(), to: Query + defdelegate footnote_ref(), to: Query + defdelegate line_break(), to: Query + + # Task list selectors + defdelegate task(), to: Query + defdelegate todo(), to: Query + defdelegate done(), to: Query + + # List / table selectors + defdelegate list(), to: Query + defdelegate list_at(n), to: Query + defdelegate table_row(n), to: Query + defdelegate table_col(n), to: Query + defdelegate table_cell(r, c), to: Query + + # MDX selectors + defdelegate mdx_jsx_flow_element(), to: Query + defdelegate mdx_text_expression(), to: Query + defdelegate mdx_jsx_text_element(), to: Query + defdelegate mdx_flow_expression(), to: Query + defdelegate mdx_js_esm(), to: Query + + # Recursive selector + defdelegate recursive(), to: Query + + # Standalone attribute selectors (0-arity) + defdelegate value(), to: Query + defdelegate node_values(), to: Query + defdelegate lang(), to: Query + defdelegate meta(), to: Query + defdelegate fence(), to: Query + defdelegate url(), to: Query + defdelegate alt(), to: Query + defdelegate depth(), to: Query + defdelegate level(), to: Query + defdelegate ordered(), to: Query + defdelegate checked(), to: Query + defdelegate column(), to: Query + defdelegate row(), to: Query + defdelegate align(), to: Query + defdelegate property(key), to: Query + + # Standalone select (no leading selector) + defdelegate select(filter), to: Query + + # Chain operations + defdelegate select(query, filter), to: Query + defdelegate map(query, filter), to: Query + + # Output format conversions + defdelegate to_text(query), to: Query + defdelegate to_markdown(query), to: Query + defdelegate to_mdx(query), to: Query + defdelegate to_html(query), to: Query + defdelegate stringify(query), to: Query + defdelegate to_number(query), to: Query + defdelegate to_array(query), to: Query + defdelegate to_bytes(query), to: Query + defdelegate to_markdown_string(query), to: Query + + # Collection operations + defdelegate length(query), to: Query + defdelegate len(query), to: Query + defdelegate utf8bytelen(query), to: Query + defdelegate add(query), to: Query + defdelegate first(query), to: Query + defdelegate last(query), to: Query + defdelegate empty(query), to: Query + defdelegate reverse(query), to: Query + defdelegate sort(query), to: Query + defdelegate compact(query), to: Query + defdelegate uniq(query), to: Query + defdelegate flatten(query), to: Query + defdelegate keys(query), to: Query + defdelegate values(query), to: Query + defdelegate entries(query), to: Query + defdelegate children(query), to: Query + defdelegate split(query, sep), to: Query + defdelegate join(query, sep), to: Query + defdelegate nth(query, n), to: Query + defdelegate limit(query, n), to: Query + defdelegate range(query, n), to: Query + defdelegate slice(query, start, stop), to: Query + defdelegate index(query, value), to: Query + defdelegate rindex(query, value), to: Query + defdelegate del(query, value), to: Query + defdelegate insert(query, idx, val), to: Query + defdelegate repeat(query, n), to: Query + + # String operations + defdelegate trim(query), to: Query + defdelegate ltrim(query), to: Query + defdelegate rtrim(query), to: Query + defdelegate downcase(query), to: Query + defdelegate upcase(query), to: Query + defdelegate ascii_downcase(query), to: Query + defdelegate ascii_upcase(query), to: Query + defdelegate explode(query), to: Query + defdelegate implode(query), to: Query + defdelegate url_encode(query), to: Query + defdelegate intern(query), to: Query + defdelegate gsub(query, pattern, replacement), to: Query + defdelegate replace(query, from, to), to: Query + defdelegate test(query, pattern), to: Query + defdelegate capture(query, pattern), to: Query + + # Math operations + defdelegate abs(query), to: Query + defdelegate ceil(query), to: Query + defdelegate floor(query), to: Query + defdelegate round(query), to: Query + defdelegate trunc(query), to: Query + defdelegate sqrt(query), to: Query + defdelegate ln(query), to: Query + defdelegate log10(query), to: Query + defdelegate exp(query), to: Query + defdelegate negate(query), to: Query + defdelegate nan?(query), to: Query + defdelegate pow(query, n), to: Query + defdelegate min(query, other), to: Query + defdelegate max(query, other), to: Query + + # Type / logic + defdelegate type(query), to: Query + defdelegate debug(query), to: Query + defdelegate coalesce(query, default), to: Query + + # Encoding + defdelegate base64(query), to: Query + defdelegate base64d(query), to: Query + defdelegate base64url(query), to: Query + defdelegate base64urld(query), to: Query + defdelegate md5(query), to: Query + defdelegate sha256(query), to: Query + defdelegate sha512(query), to: Query + defdelegate from_hex(query), to: Query + defdelegate to_hex(query), to: Query + + # Path operations + defdelegate basename(query), to: Query + defdelegate dirname(query), to: Query + defdelegate extname(query), to: Query + defdelegate stem(query), to: Query + defdelegate path_join(query, other), to: Query + + # Dict operations + defdelegate get(query, key), to: Query + defdelegate set(query, key, val), to: Query + + # Chained attribute selectors (1-arity) + defdelegate value(query), to: Query + defdelegate lang(query), to: Query + defdelegate meta(query), to: Query + defdelegate fence(query), to: Query + defdelegate url(query), to: Query + defdelegate alt(query), to: Query + defdelegate title(query), to: Query + defdelegate ident(query), to: Query + defdelegate label(query), to: Query + defdelegate depth(query), to: Query + defdelegate level(query), to: Query + defdelegate item_index(query), to: Query + defdelegate ordered(query), to: Query + defdelegate checked(query), to: Query + defdelegate column(query), to: Query + defdelegate row(query), to: Query + defdelegate align(query), to: Query + defdelegate mdx_name(query), to: Query + defdelegate property(query, key), to: Query + + # Markdown mutation + defdelegate update(query, content), to: Query + defdelegate attr(query, name), to: Query + defdelegate set_attr(query, name, val), to: Query + defdelegate get_title(query), to: Query + defdelegate get_url(query), to: Query + defdelegate set_check(query, val), to: Query + defdelegate set_ref(query, ref), to: Query + defdelegate set_code_block_lang(query, lang), to: Query + defdelegate set_list_ordered(query, val), to: Query + + # Markdown construction + defdelegate to_code(query), to: Query + defdelegate to_code(query, lang), to: Query + defdelegate to_code_inline(query), to: Query + defdelegate to_h(query, depth), to: Query + defdelegate to_hr(query), to: Query + defdelegate to_link(query, url), to: Query + defdelegate to_link(query, url, text), to: Query + defdelegate to_link(query, url, text, link_title), to: Query + defdelegate to_image(query, url), to: Query + defdelegate to_image(query, url, img_alt), to: Query + defdelegate to_image(query, url, img_alt, img_title), to: Query + defdelegate to_math(query), to: Query + defdelegate to_math_inline(query), to: Query + defdelegate to_strong(query), to: Query + defdelegate to_em(query), to: Query + defdelegate to_md_text(query), to: Query + defdelegate to_md_list(query, list_level), to: Query + defdelegate to_md_name(query, node_name), to: Query + defdelegate to_md_table_row(query, cells), to: Query + defdelegate to_md_table_cell(query, content, r, c), to: Query + + # Conversion + defdelegate to_query_string(query), to: Query end diff --git a/lib/mq/filter.ex b/lib/mq/filter.ex new file mode 100644 index 0000000..1df294d --- /dev/null +++ b/lib/mq/filter.ex @@ -0,0 +1,176 @@ +defmodule Mq.Filter do + @moduledoc """ + Filter expressions for use inside `select()` and `map()` in mq queries. + + Filters are constructed by calling the functions in this module. They can be + combined with `and_filter/2`, `or_filter/2`, and `negate/1`, and work + naturally with the `|>` pipe operator. + + ## Examples + + iex> Mq.Filter.contains("Feature") + #Mq.Filter + + iex> Mq.Filter.contains("Feature") |> Mq.Filter.and_filter(Mq.Filter.starts_with("##")) |> to_string() + "contains(\\"Feature\\") and starts_with(\\"##\\")" + + iex> Mq.Filter.all([Mq.Filter.contains("API"), Mq.Filter.ne("## Draft")]) + #Mq.Filter + + Use filters with `Mq.Query.select/2`: + + iex> alias Mq.{Query, Filter} + iex> Query.h2() |> Query.select(Filter.contains("Feature")) |> to_string() + ".h2 | select(contains(\\"Feature\\"))" + """ + + @type t :: %__MODULE__{expr: String.t()} + + defstruct [:expr] + + defp new(expr), do: %__MODULE__{expr: expr} + + # String matching + + @doc "Match nodes whose text contains `text`." + def contains(text), do: new("contains(#{qs(text)})") + + @doc "Match nodes whose text starts with `text`." + def starts_with(text), do: new("starts_with(#{qs(text)})") + + @doc "Match nodes whose text ends with `text`." + def ends_with(text), do: new("ends_with(#{qs(text)})") + + @doc "Match nodes whose text matches the regex `pattern`." + def test(pattern), do: new("test(#{qs(pattern)})") + + # Regex + + @doc "Match nodes whose text matches the regex `pattern`." + def regex_match?(pattern), do: new("is_regex_match(#{qs(pattern)})") + + @doc "Match nodes whose text does not match the regex `pattern`." + def not_regex_match?(pattern), do: new("is_not_regex_match(#{qs(pattern)})") + + # Comparison + + @doc "Match nodes equal to `value`." + def eq(value), do: new("eq(#{qv(value)})") + + @doc "Match nodes not equal to `value`." + def ne(value), do: new("ne(#{qv(value)})") + + @doc "Match nodes greater than `value`." + def gt(value), do: new("gt(#{qv(value)})") + + @doc "Match nodes greater than or equal to `value`." + def gte(value), do: new("gte(#{qv(value)})") + + @doc "Match nodes less than `value`." + def lt(value), do: new("lt(#{qv(value)})") + + @doc "Match nodes less than or equal to `value`." + def lte(value), do: new("lte(#{qv(value)})") + + # Type checks + + @doc "Match MDX nodes." + def mdx?, do: new("is_mdx()") + + @doc "Match nodes with no value (none/null)." + def none?, do: new("is_none()") + + @doc "Match nodes whose numeric value is NaN." + def nan?, do: new("is_nan()") + + @doc "Filter by the node type string." + def type, do: new("type") + + # Value transforms usable in filter context + + @doc "The length of the current value." + def length, do: new("length") + + @doc "Lowercase the current value (ASCII only)." + def ascii_downcase, do: new("ascii_downcase()") + + @doc "Uppercase the current value (ASCII only)." + def ascii_upcase, do: new("ascii_upcase()") + + @doc "Trim whitespace from the current value." + def trim, do: new("trim()") + + @doc "Match empty nodes." + def empty, do: new("empty") + + @doc "Add/concatenate the current value." + def add, do: new("add") + + # Boolean combinators + + @doc """ + Combine two filters with boolean AND (`&&`). + + Works naturally with `|>`: + + Filter.contains("API") |> Filter.and_filter(Filter.ne("## Draft")) + """ + def and_filter(%__MODULE__{expr: a}, %__MODULE__{expr: b}), do: new("#{a} && #{b}") + + @doc """ + Combine two filters with boolean OR. + + Works naturally with `|>`: + + Filter.contains("A") |> Filter.or_filter(Filter.contains("B")) + """ + def or_filter(%__MODULE__{expr: a}, %__MODULE__{expr: b}), do: new("#{a} || #{b}") + + @doc """ + Combine a list of filters with AND (all must match). + + Filter.all([Filter.contains("API"), Filter.ne("## Draft"), Filter.starts_with("## ")]) + """ + def all([single]), do: single + + def all([head | rest]) do + Enum.reduce(rest, head, &and_filter(&2, &1)) + end + + @doc """ + Combine a list of filters with OR (any must match). + + Filter.any([Filter.contains("A"), Filter.contains("B")]) + """ + def any([single]), do: single + + def any([head | rest]) do + Enum.reduce(rest, head, &or_filter(&2, &1)) + end + + @doc """ + Negate a filter with `not(...)`. + + Filter.negate(Filter.contains("draft")) + # => not(contains("draft")) + """ + def negate(%__MODULE__{expr: expr}), do: new("not(#{expr})") + + @doc "Return the raw filter expression string." + def to_filter_string(%__MODULE__{expr: expr}), do: expr + + # Inspect string: wraps the value in double quotes. + defp qs(text), do: inspect(text) + + # Inspect value: strings get quotes, numbers/booleans are bare. + defp qv(value) when is_binary(value), do: inspect(value) + defp qv(value), do: to_string(value) +end + +defimpl String.Chars, for: Mq.Filter do + def to_string(%Mq.Filter{expr: expr}), do: expr +end + +defimpl Inspect, for: Mq.Filter do + def inspect(%Mq.Filter{expr: expr}, _opts), do: "#Mq.Filter<#{expr}>" +end diff --git a/lib/mq/query.ex b/lib/mq/query.ex new file mode 100644 index 0000000..e4ca961 --- /dev/null +++ b/lib/mq/query.ex @@ -0,0 +1,713 @@ +defmodule Mq.Query do + @moduledoc """ + Programmatic query builder for constructing mq queries. + + Queries are built by starting with a **selector** (e.g. `h2/0`, `code/0`) and + chaining operations via the `|>` pipe operator. The resulting struct can be + passed directly to `Mq.run/3` or converted to its string form with + `to_string/1` / `Kernel.to_string/1`. + + ## Basic usage + + iex> Mq.Query.h2() |> to_string() + ".h2" + + iex> Mq.Query.h2() |> Mq.Query.to_text() |> to_string() + ".h2 | to_text()" + + ## Filters + + Use `Mq.Filter` to build filter expressions for `select/2` and `map/2`: + + iex> alias Mq.{Query, Filter} + iex> Query.h2() |> Query.select(Filter.contains("Feature")) |> to_string() + ".h2 | select(contains(\\"Feature\\"))" + + ## Combined filters + + iex> alias Mq.{Query, Filter} + iex> query = Query.h2() + ...> |> Query.select( + ...> Filter.contains("API") + ...> |> Filter.and_filter(Filter.negate(Filter.contains("Internal"))) + ...> ) + ...> |> Query.to_text() + ...> |> Query.downcase() + iex> to_string(query) + ".h2 | select(contains(\\"API\\") and not(contains(\\"Internal\\"))) | to_text() | downcase()" + + ## Using with `Mq.run/3` + + iex> {:ok, result} = Mq.run(Mq.Query.h2(), "# Hello\\n## World") + iex> result.values + ["## World"] + """ + + @type t :: %__MODULE__{expr: String.t()} + + defstruct [:expr] + + defp new(expr), do: %__MODULE__{expr: expr} + + defp pipe_expr(%__MODULE__{expr: ""}, next), do: new(next) + defp pipe_expr(%__MODULE__{expr: expr}, next), do: new("#{expr} | #{next}") + + # Heading selectors + for n <- 1..6 do + @doc "Select all h#{n} headings." + def unquote(:"h#{n}")(), do: new(unquote(".h#{n}")) + end + + @doc "Select all headings (any level)." + def heading, do: new(".heading") + + # Block element selectors + @doc "Select all code blocks." + def code, do: new(".code") + + @doc "Select all paragraphs." + def paragraph, do: new(".p") + + @doc "Select all blockquotes." + def blockquote, do: new(".blockquote") + + @doc "Select all horizontal rules." + def hr, do: new(".hr") + + @doc "Select all images." + def image, do: new(".image") + + @doc "Select all links." + def link, do: new(".link") + + @doc "Select all text nodes." + def text, do: new(".text") + + @doc "Select all strong (bold) nodes." + def strong, do: new(".strong") + + @doc "Select all emphasis (italic) nodes." + def emphasis, do: new(".emphasis") + + @doc "Select all strikethrough (delete) nodes." + def delete, do: new(".delete") + + @doc "Select all math blocks." + def math, do: new(".math") + + @doc "Select all tables." + def table, do: new(".table") + + @doc "Select all table alignment nodes." + def table_align, do: new(".table_align") + + @doc "Select all raw HTML nodes." + def html, do: new(".html") + + @doc "Select all link definition nodes." + def definition, do: new(".definition") + + @doc "Select all footnote definition nodes." + def footnote, do: new(".footnote") + + @doc "Select all TOML front matter nodes." + def toml, do: new(".toml") + + @doc "Select all YAML front matter nodes." + def yaml, do: new(".yaml") + + # Inline element selectors + @doc "Select all inline code spans." + def code_inline, do: new(".code_inline") + + @doc "Select all inline math spans." + def math_inline, do: new(".math_inline") + + @doc "Select all link reference nodes." + def link_ref, do: new(".link_ref") + + @doc "Select all image reference nodes." + def image_ref, do: new(".image_ref") + + @doc "Select all footnote reference nodes." + def footnote_ref, do: new(".footnote_ref") + + @doc "Select all line break nodes." + def line_break, do: new(".break") + + # Task list selectors + @doc "Select all task list items." + def task, do: new(".task") + + @doc "Select all unchecked task list items." + def todo, do: new(".todo") + + @doc "Select all checked task list items." + def done, do: new(".done") + + # List and table selectors + @doc "Select all list items." + def list, do: new(".[]") + + @doc "Select the list item at index `n`." + def list_at(n), do: new(".[#{n}]") + + @doc "Select all cells in table row `n`." + def table_row(n), do: new(".[#{n}][]") + + @doc "Select all cells in table column `n`." + def table_col(n), do: new(".[][#{n}]") + + @doc "Select the table cell at row `r`, column `c`." + def table_cell(r, c), do: new(".[#{r}][#{c}]") + + # MDX selectors + @doc "Select all MDX JSX flow elements." + def mdx_jsx_flow_element, do: new(".mdx_jsx_flow_element") + + @doc "Select all MDX text expression nodes." + def mdx_text_expression, do: new(".mdx_text_expression") + + @doc "Select all MDX JSX text elements." + def mdx_jsx_text_element, do: new(".mdx_jsx_text_element") + + @doc "Select all MDX flow expression nodes." + def mdx_flow_expression, do: new(".mdx_flow_expression") + + @doc "Select all MDX JS/ESM import/export nodes." + def mdx_js_esm, do: new(".mdx_js_esm") + + # Recursive (deep) selector + @doc "Recursive / deep selector — descend into all children." + def recursive, do: new("..") + + # Standalone attribute selectors (0-arity) + # These also exist as 1-arity chain operations below. + + @doc "Standalone `.value` attribute selector." + def value, do: new(".value") + + @doc "Standalone `.values` attribute selector." + def node_values, do: new(".values") + + @doc "Standalone `.lang` attribute selector." + def lang, do: new(".lang") + + @doc "Standalone `.meta` attribute selector." + def meta, do: new(".meta") + + @doc "Standalone `.fence` attribute selector." + def fence, do: new(".fence") + + @doc "Standalone `.url` attribute selector." + def url, do: new(".url") + + @doc "Standalone `.alt` attribute selector." + def alt, do: new(".alt") + + @doc "Standalone `.depth` attribute selector." + def depth, do: new(".depth") + + @doc "Standalone `.level` attribute selector." + def level, do: new(".level") + + @doc "Standalone `.ordered` attribute selector." + def ordered, do: new(".ordered") + + @doc "Standalone `.checked` attribute selector." + def checked, do: new(".checked") + + @doc "Standalone `.column` attribute selector." + def column, do: new(".column") + + @doc "Standalone `.row` attribute selector." + def row, do: new(".row") + + @doc "Standalone `.align` attribute selector." + def align, do: new(".align") + + @doc "Standalone dict property selector: `.\"key\"`." + def property(key), do: new(".\"#{key}\"") + + # Standalone select (no leading selector) + @doc """ + Standalone `select(filter)` — no leading selector. + + Query.select(Filter.mdx?()) + # => "select(is_mdx())" + """ + def select(%Mq.Filter{expr: expr}), do: new("select(#{expr})") + def select(filter) when is_binary(filter), do: new("select(#{filter})") + + # Chained operations + @doc "Append a `select(filter)` step." + def select(%__MODULE__{} = q, %Mq.Filter{expr: expr}), do: pipe_expr(q, "select(#{expr})") + + def select(%__MODULE__{} = q, filter) when is_binary(filter), + do: pipe_expr(q, "select(#{filter})") + + @doc "Append a `map(filter)` step." + def map(%__MODULE__{} = q, %Mq.Filter{expr: expr}), do: pipe_expr(q, "map(#{expr})") + def map(%__MODULE__{} = q, filter) when is_binary(filter), do: pipe_expr(q, "map(#{filter})") + + # Output format conversions + @doc "Convert to plain text." + def to_text(%__MODULE__{} = q), do: pipe_expr(q, "to_text()") + + @doc "Convert to Markdown." + def to_markdown(%__MODULE__{} = q), do: pipe_expr(q, "to_markdown()") + + @doc "Convert to MDX." + def to_mdx(%__MODULE__{} = q), do: pipe_expr(q, "to_mdx()") + + @doc "Convert to HTML." + def to_html(%__MODULE__{} = q), do: pipe_expr(q, "to_html()") + + @doc "Convert to a string value (mq `to_string()` function)." + def stringify(%__MODULE__{} = q), do: pipe_expr(q, "to_string()") + + @doc "Convert to a number value." + def to_number(%__MODULE__{} = q), do: pipe_expr(q, "to_number()") + + @doc "Convert to an array value." + def to_array(%__MODULE__{} = q), do: pipe_expr(q, "to_array()") + + @doc "Convert to bytes." + def to_bytes(%__MODULE__{} = q), do: pipe_expr(q, "to_bytes()") + + @doc "Convert to a Markdown string (serialized)." + def to_markdown_string(%__MODULE__{} = q), do: pipe_expr(q, "to_markdown_string()") + + # Collection operations + @doc "Return the length of the current value." + def length(%__MODULE__{} = q), do: pipe_expr(q, "length") + + @doc "Return the byte length of the current value." + def len(%__MODULE__{} = q), do: pipe_expr(q, "len()") + + @doc "Return the UTF-8 byte length of the current value." + def utf8bytelen(%__MODULE__{} = q), do: pipe_expr(q, "utf8bytelen()") + + @doc "Add/concatenate the current value." + def add(%__MODULE__{} = q), do: pipe_expr(q, "add") + + @doc "Return the first element." + def first(%__MODULE__{} = q), do: pipe_expr(q, "first") + + @doc "Return the last element." + def last(%__MODULE__{} = q), do: pipe_expr(q, "last") + + @doc "Emit nothing (empty result)." + def empty(%__MODULE__{} = q), do: pipe_expr(q, "empty") + + @doc "Reverse the current value." + def reverse(%__MODULE__{} = q), do: pipe_expr(q, "reverse") + + @doc "Sort the current value." + def sort(%__MODULE__{} = q), do: pipe_expr(q, "sort") + + @doc "Remove nil/null entries." + def compact(%__MODULE__{} = q), do: pipe_expr(q, "compact") + + @doc "Remove duplicate entries." + def uniq(%__MODULE__{} = q), do: pipe_expr(q, "uniq") + + @doc "Flatten nested arrays." + def flatten(%__MODULE__{} = q), do: pipe_expr(q, "flatten") + + @doc "Return the keys of a dict." + def keys(%__MODULE__{} = q), do: pipe_expr(q, "keys") + + @doc "Return the values of a dict." + def values(%__MODULE__{} = q), do: pipe_expr(q, "values") + + @doc "Return the key-value entries of a dict." + def entries(%__MODULE__{} = q), do: pipe_expr(q, "entries") + + @doc "Descend into children." + def children(%__MODULE__{} = q), do: pipe_expr(q, ".children") + + @doc "Split the current value by `sep`." + def split(%__MODULE__{} = q, sep), do: pipe_expr(q, "split(#{inspect(sep)})") + + @doc "Join elements with `sep`." + def join(%__MODULE__{} = q, sep), do: pipe_expr(q, "join(#{inspect(sep)})") + + @doc "Select the nth element (0-based)." + def nth(%__MODULE__{} = q, n), do: pipe_expr(q, "nth(#{n})") + + @doc "Limit output to `n` elements." + def limit(%__MODULE__{} = q, n), do: pipe_expr(q, "limit(#{n})") + + @doc "Take a range of `n` elements." + def range(%__MODULE__{} = q, n), do: pipe_expr(q, "range(#{n})") + + @doc "Slice elements from `start` to `stop`." + def slice(%__MODULE__{} = q, start, stop), do: pipe_expr(q, "slice(#{start}, #{stop})") + + @doc "Find the first index of `value`." + def index(%__MODULE__{} = q, value), do: pipe_expr(q, "index(#{inspect(value)})") + + @doc "Find the last index of `value`." + def rindex(%__MODULE__{} = q, value), do: pipe_expr(q, "rindex(#{inspect(value)})") + + @doc "Delete the element `value`." + def del(%__MODULE__{} = q, value), do: pipe_expr(q, "del(#{inspect(value)})") + + @doc "Insert `val` at index `idx`." + def insert(%__MODULE__{} = q, idx, val), do: pipe_expr(q, "insert(#{idx}, #{inspect(val)})") + + @doc "Repeat the current value `n` times." + def repeat(%__MODULE__{} = q, n), do: pipe_expr(q, "repeat(#{n})") + + # String operations + @doc "Trim leading and trailing whitespace." + def trim(%__MODULE__{} = q), do: pipe_expr(q, "trim()") + + @doc "Trim leading whitespace." + def ltrim(%__MODULE__{} = q), do: pipe_expr(q, "ltrim()") + + @doc "Trim trailing whitespace." + def rtrim(%__MODULE__{} = q), do: pipe_expr(q, "rtrim()") + + @doc "Convert to lowercase (Unicode-aware)." + def downcase(%__MODULE__{} = q), do: pipe_expr(q, "downcase()") + + @doc "Convert to uppercase (Unicode-aware)." + def upcase(%__MODULE__{} = q), do: pipe_expr(q, "upcase()") + + @doc "Convert to lowercase (ASCII only)." + def ascii_downcase(%__MODULE__{} = q), do: pipe_expr(q, "ascii_downcase()") + + @doc "Convert to uppercase (ASCII only)." + def ascii_upcase(%__MODULE__{} = q), do: pipe_expr(q, "ascii_upcase()") + + @doc "Explode a string into codepoints." + def explode(%__MODULE__{} = q), do: pipe_expr(q, "explode()") + + @doc "Implode codepoints back into a string." + def implode(%__MODULE__{} = q), do: pipe_expr(q, "implode()") + + @doc "URL-encode the current value." + def url_encode(%__MODULE__{} = q), do: pipe_expr(q, "url_encode()") + + @doc "Intern the current string." + def intern(%__MODULE__{} = q), do: pipe_expr(q, "intern()") + + @doc "Replace all occurrences of `pattern` with `replacement` (regex)." + def gsub(%__MODULE__{} = q, pattern, replacement), + do: pipe_expr(q, "gsub(#{inspect(pattern)}, #{inspect(replacement)})") + + @doc "Replace the first occurrence of `from` with `to`." + def replace(%__MODULE__{} = q, from, to), + do: pipe_expr(q, "replace(#{inspect(from)}, #{inspect(to)})") + + @doc "Test whether the current value matches `pattern` (regex)." + def test(%__MODULE__{} = q, pattern), do: pipe_expr(q, "test(#{inspect(pattern)})") + + @doc "Capture groups from `pattern` (regex)." + def capture(%__MODULE__{} = q, pattern), do: pipe_expr(q, "capture(#{inspect(pattern)})") + + # Math operations + @doc "Absolute value." + def abs(%__MODULE__{} = q), do: pipe_expr(q, "abs()") + + @doc "Ceiling." + def ceil(%__MODULE__{} = q), do: pipe_expr(q, "ceil()") + + @doc "Floor." + def floor(%__MODULE__{} = q), do: pipe_expr(q, "floor()") + + @doc "Round." + def round(%__MODULE__{} = q), do: pipe_expr(q, "round()") + + @doc "Truncate." + def trunc(%__MODULE__{} = q), do: pipe_expr(q, "trunc()") + + @doc "Square root." + def sqrt(%__MODULE__{} = q), do: pipe_expr(q, "sqrt()") + + @doc "Natural logarithm." + def ln(%__MODULE__{} = q), do: pipe_expr(q, "ln()") + + @doc "Base-10 logarithm." + def log10(%__MODULE__{} = q), do: pipe_expr(q, "log10()") + + @doc "Exponential (e^x)." + def exp(%__MODULE__{} = q), do: pipe_expr(q, "exp()") + + @doc "Negate the current numeric value." + def negate(%__MODULE__{} = q), do: pipe_expr(q, "negate()") + + @doc "Check whether the current value is NaN." + def nan?(%__MODULE__{} = q), do: pipe_expr(q, "is_nan()") + + @doc "Raise the current value to the power of `n`." + def pow(%__MODULE__{} = q, n), do: pipe_expr(q, "pow(#{n})") + + @doc "Return the smaller of the current value and `other`." + def min(%__MODULE__{} = q, other), do: pipe_expr(q, "min(#{other})") + + @doc "Return the larger of the current value and `other`." + def max(%__MODULE__{} = q, other), do: pipe_expr(q, "max(#{other})") + + # Type / logic + @doc "Return the type of the current value." + def type(%__MODULE__{} = q), do: pipe_expr(q, "type") + + @doc "Emit debug information for the current value." + def debug(%__MODULE__{} = q), do: pipe_expr(q, "debug") + + @doc "Return `default` when the current value is none/null." + def coalesce(%__MODULE__{} = q, default), do: pipe_expr(q, "coalesce(#{inspect(default)})") + + # Encoding + @doc "Base64-encode." + def base64(%__MODULE__{} = q), do: pipe_expr(q, "base64()") + + @doc "Base64-decode." + def base64d(%__MODULE__{} = q), do: pipe_expr(q, "base64d()") + + @doc "Base64url-encode." + def base64url(%__MODULE__{} = q), do: pipe_expr(q, "base64url()") + + @doc "Base64url-decode." + def base64urld(%__MODULE__{} = q), do: pipe_expr(q, "base64urld()") + + @doc "MD5 hash." + def md5(%__MODULE__{} = q), do: pipe_expr(q, "md5()") + + @doc "SHA-256 hash." + def sha256(%__MODULE__{} = q), do: pipe_expr(q, "sha256()") + + @doc "SHA-512 hash." + def sha512(%__MODULE__{} = q), do: pipe_expr(q, "sha512()") + + @doc "Decode from hex." + def from_hex(%__MODULE__{} = q), do: pipe_expr(q, "from_hex()") + + @doc "Encode to hex." + def to_hex(%__MODULE__{} = q), do: pipe_expr(q, "to_hex()") + + # Path operations + @doc "Return the basename of a path." + def basename(%__MODULE__{} = q), do: pipe_expr(q, "basename()") + + @doc "Return the directory part of a path." + def dirname(%__MODULE__{} = q), do: pipe_expr(q, "dirname()") + + @doc "Return the file extension." + def extname(%__MODULE__{} = q), do: pipe_expr(q, "extname()") + + @doc "Return the filename stem (basename without extension)." + def stem(%__MODULE__{} = q), do: pipe_expr(q, "stem()") + + @doc "Join the current path with `other`." + def path_join(%__MODULE__{} = q, other), do: pipe_expr(q, "path_join(#{inspect(other)})") + + # Dict operations + @doc "Get the value at dict key `key`." + def get(%__MODULE__{} = q, key), do: pipe_expr(q, "get(#{inspect(key)})") + + @doc "Set dict key `key` to `val`." + def set(%__MODULE__{} = q, key, val), do: pipe_expr(q, "set(#{inspect(key)}, #{inspect(val)})") + + # Chained attribute selectors (1-arity, dual with standalone 0-arity) + @doc "Access the `.value` attribute of the selected node." + def value(%__MODULE__{} = q), do: pipe_expr(q, ".value") + + @doc "Access the `.lang` attribute (e.g. code block language)." + def lang(%__MODULE__{} = q), do: pipe_expr(q, ".lang") + + @doc "Access the `.meta` attribute." + def meta(%__MODULE__{} = q), do: pipe_expr(q, ".meta") + + @doc "Access the `.fence` attribute." + def fence(%__MODULE__{} = q), do: pipe_expr(q, ".fence") + + @doc "Access the `.url` attribute." + def url(%__MODULE__{} = q), do: pipe_expr(q, ".url") + + @doc "Access the `.alt` attribute." + def alt(%__MODULE__{} = q), do: pipe_expr(q, ".alt") + + @doc "Access the `.title` attribute." + def title(%__MODULE__{} = q), do: pipe_expr(q, ".title") + + @doc "Access the `.ident` attribute." + def ident(%__MODULE__{} = q), do: pipe_expr(q, ".ident") + + @doc "Access the `.label` attribute." + def label(%__MODULE__{} = q), do: pipe_expr(q, ".label") + + @doc "Access the `.depth` attribute." + def depth(%__MODULE__{} = q), do: pipe_expr(q, ".depth") + + @doc "Access the `.level` attribute." + def level(%__MODULE__{} = q), do: pipe_expr(q, ".level") + + @doc "Access the `.index` attribute (item index in a list)." + def item_index(%__MODULE__{} = q), do: pipe_expr(q, ".index") + + @doc "Access the `.ordered` attribute." + def ordered(%__MODULE__{} = q), do: pipe_expr(q, ".ordered") + + @doc "Access the `.checked` attribute (task list)." + def checked(%__MODULE__{} = q), do: pipe_expr(q, ".checked") + + @doc "Access the `.column` attribute." + def column(%__MODULE__{} = q), do: pipe_expr(q, ".column") + + @doc "Access the `.row` attribute." + def row(%__MODULE__{} = q), do: pipe_expr(q, ".row") + + @doc "Access the `.align` attribute." + def align(%__MODULE__{} = q), do: pipe_expr(q, ".align") + + @doc "Access the `.name` attribute (MDX element name)." + def mdx_name(%__MODULE__{} = q), do: pipe_expr(q, ".name") + + @doc "Access a dict property by key (generates `.\"key\"`)." + def property(%__MODULE__{} = q, key), do: pipe_expr(q, ".\"#{key}\"") + + # Markdown attribute mutation + @doc "Update the node content to `content`." + def update(%__MODULE__{} = q, content), do: pipe_expr(q, "update(#{inspect(content)})") + + @doc "Get the attribute named `name`." + def attr(%__MODULE__{} = q, name), do: pipe_expr(q, "attr(#{inspect(name)})") + + @doc "Set the attribute named `name` to `val`." + def set_attr(%__MODULE__{} = q, name, val), + do: pipe_expr(q, "set_attr(#{inspect(name)}, #{inspect(val)})") + + @doc "Get the title of a link or image." + def get_title(%__MODULE__{} = q), do: pipe_expr(q, "get_title") + + @doc "Get the URL of a link or image." + def get_url(%__MODULE__{} = q), do: pipe_expr(q, "get_url") + + @doc "Set the checked state of a task list item." + def set_check(%__MODULE__{} = q, val), do: pipe_expr(q, "set_check(#{val})") + + @doc "Set the reference identifier of a link ref or image ref." + def set_ref(%__MODULE__{} = q, ref), do: pipe_expr(q, "set_ref(#{inspect(ref)})") + + @doc "Set the language of a code block." + def set_code_block_lang(%__MODULE__{} = q, lang), + do: pipe_expr(q, "set_code_block_lang(#{inspect(lang)})") + + @doc "Set whether a list is ordered." + def set_list_ordered(%__MODULE__{} = q, val), do: pipe_expr(q, "set_list_ordered(#{val})") + + # Markdown construction + @doc """ + Convert to a fenced code block. + + - `to_code(q)` — no language (`null`) + - `to_code(q, "elixir")` — with language + """ + def to_code(%__MODULE__{} = q, lang \\ nil) do + pipe_expr(q, if(lang, do: "to_code(#{inspect(lang)})", else: "to_code(null)")) + end + + @doc "Convert to an inline code span." + def to_code_inline(%__MODULE__{} = q), do: pipe_expr(q, "to_code_inline()") + + @doc "Convert to a heading of the given `depth` (1–6)." + def to_h(%__MODULE__{} = q, depth), do: pipe_expr(q, "to_h(#{depth})") + + @doc "Convert to a horizontal rule." + def to_hr(%__MODULE__{} = q), do: pipe_expr(q, "to_hr()") + + @doc """ + Convert to a link node. + + - `to_link(q, url)` — current value becomes the link text + - `to_link(q, url, text)` — explicit text, empty title + - `to_link(q, url, text, title)` — explicit text and title + """ + def to_link(%__MODULE__{} = q, url) do + pipe_expr(q, "to_link(#{inspect(url)}, \"\")") + end + + def to_link(%__MODULE__{} = q, url, text) do + pipe_expr(q, "to_link(#{inspect(url)}, #{inspect(text)}, \"\")") + end + + def to_link(%__MODULE__{} = q, url, text, link_title) do + pipe_expr(q, "to_link(#{inspect(url)}, #{inspect(text)}, #{inspect(link_title)})") + end + + @doc """ + Convert to an image node. + + - `to_image(q, url)` — current value becomes the alt text + - `to_image(q, url, alt)` — explicit alt, empty title + - `to_image(q, url, alt, title)` — explicit alt and title + """ + def to_image(%__MODULE__{} = q, url) do + pipe_expr(q, "to_image(#{inspect(url)}, \"\")") + end + + def to_image(%__MODULE__{} = q, url, img_alt) do + pipe_expr(q, "to_image(#{inspect(url)}, #{inspect(img_alt)}, \"\")") + end + + def to_image(%__MODULE__{} = q, url, img_alt, img_title) do + pipe_expr(q, "to_image(#{inspect(url)}, #{inspect(img_alt)}, #{inspect(img_title)})") + end + + @doc "Convert to a math block." + def to_math(%__MODULE__{} = q), do: pipe_expr(q, "to_math()") + + @doc "Convert to an inline math span." + def to_math_inline(%__MODULE__{} = q), do: pipe_expr(q, "to_math_inline()") + + @doc "Convert to a strong (bold) node." + def to_strong(%__MODULE__{} = q), do: pipe_expr(q, "to_strong()") + + @doc "Convert to an emphasis (italic) node." + def to_em(%__MODULE__{} = q), do: pipe_expr(q, "to_em()") + + @doc "Convert to a plain Markdown text node." + def to_md_text(%__MODULE__{} = q), do: pipe_expr(q, "to_md_text()") + + @doc "Convert to a list item at nesting `list_level`." + def to_md_list(%__MODULE__{} = q, list_level), do: pipe_expr(q, "to_md_list(#{list_level})") + + @doc "Convert to a Markdown element with the given node `node_name`." + def to_md_name(%__MODULE__{} = q, node_name), + do: pipe_expr(q, "to_md_name(#{inspect(node_name)})") + + @doc "Build a table row from the given `cells` list." + def to_md_table_row(%__MODULE__{} = q, cells) when is_list(cells) do + cell_str = Enum.map_join(cells, ", ", &inspect/1) + pipe_expr(q, "to_md_table_row(#{cell_str})") + end + + @doc "Build a table cell with `content` at row `r`, column `c`." + def to_md_table_cell(%__MODULE__{} = q, content, r, c), + do: pipe_expr(q, "to_md_table_cell(#{inspect(content)}, #{r}, #{c})") + + # Pipe two Query structs + @doc """ + Pipe two queries together. + + Query.h2() |> Query.pipe(Query.to_text()) + # => ".h2 | to_text()" + """ + def pipe(%__MODULE__{} = q1, %__MODULE__{expr: expr2}), do: pipe_expr(q1, expr2) + + # Conversion + @doc "Return the raw mq query string." + def to_query_string(%__MODULE__{expr: expr}), do: expr +end + +defimpl String.Chars, for: Mq.Query do + def to_string(%Mq.Query{expr: expr}), do: expr +end + +defimpl Inspect, for: Mq.Query do + def inspect(%Mq.Query{expr: expr}, _opts), do: "#Mq.Query<#{expr}>" +end diff --git a/test/mq_test.exs b/test/mq_test.exs index 88304ed..0064ab1 100644 --- a/test/mq_test.exs +++ b/test/mq_test.exs @@ -2,6 +2,8 @@ defmodule MqTest do use ExUnit.Case, async: true doctest Mq + alias Mq.{Filter, Query} + describe "run/3" do test "extracts h1 headings" do content = "# Hello World\n\n## Heading2\n\nText" @@ -149,4 +151,468 @@ defmodule MqTest do refute Mq.InputFormat.valid?(:invalid) end end + + describe "Mq.Query selectors" do + test "h1 through h6" do + for n <- 1..6 do + assert to_string(apply(Query, :"h#{n}", [])) == ".h#{n}" + end + end + + test "block element selectors" do + assert to_string(Query.heading()) == ".heading" + assert to_string(Query.code()) == ".code" + assert to_string(Query.paragraph()) == ".p" + assert to_string(Query.blockquote()) == ".blockquote" + assert to_string(Query.image()) == ".image" + assert to_string(Query.link()) == ".link" + assert to_string(Query.text()) == ".text" + assert to_string(Query.strong()) == ".strong" + assert to_string(Query.emphasis()) == ".emphasis" + assert to_string(Query.delete()) == ".delete" + assert to_string(Query.math()) == ".math" + assert to_string(Query.table()) == ".table" + assert to_string(Query.table_align()) == ".table_align" + assert to_string(Query.html()) == ".html" + assert to_string(Query.definition()) == ".definition" + assert to_string(Query.footnote()) == ".footnote" + assert to_string(Query.toml()) == ".toml" + assert to_string(Query.yaml()) == ".yaml" + end + + test "inline element selectors" do + assert to_string(Query.code_inline()) == ".code_inline" + assert to_string(Query.math_inline()) == ".math_inline" + assert to_string(Query.link_ref()) == ".link_ref" + assert to_string(Query.image_ref()) == ".image_ref" + assert to_string(Query.footnote_ref()) == ".footnote_ref" + assert to_string(Query.line_break()) == ".break" + end + + test "task list selectors" do + assert to_string(Query.task()) == ".task" + assert to_string(Query.todo()) == ".todo" + assert to_string(Query.done()) == ".done" + end + + test "list and table selectors" do + assert to_string(Query.list()) == ".[]" + assert to_string(Query.list_at(0)) == ".[0]" + assert to_string(Query.list_at(2)) == ".[2]" + assert to_string(Query.table_row(0)) == ".[0][]" + assert to_string(Query.table_col(1)) == ".[][1]" + assert to_string(Query.table_cell(1, 2)) == ".[1][2]" + end + + test "mdx selectors" do + assert to_string(Query.mdx_jsx_flow_element()) == ".mdx_jsx_flow_element" + assert to_string(Query.mdx_text_expression()) == ".mdx_text_expression" + assert to_string(Query.mdx_jsx_text_element()) == ".mdx_jsx_text_element" + assert to_string(Query.mdx_flow_expression()) == ".mdx_flow_expression" + assert to_string(Query.mdx_js_esm()) == ".mdx_js_esm" + end + + test "recursive selector" do + assert to_string(Query.recursive()) == ".." + end + + test "standalone attribute selectors" do + assert to_string(Query.value()) == ".value" + assert to_string(Query.lang()) == ".lang" + assert to_string(Query.meta()) == ".meta" + assert to_string(Query.fence()) == ".fence" + assert to_string(Query.url()) == ".url" + assert to_string(Query.alt()) == ".alt" + assert to_string(Query.depth()) == ".depth" + assert to_string(Query.level()) == ".level" + assert to_string(Query.ordered()) == ".ordered" + assert to_string(Query.checked()) == ".checked" + assert to_string(Query.column()) == ".column" + assert to_string(Query.row()) == ".row" + assert to_string(Query.align()) == ".align" + end + + test "property/1 selector" do + assert to_string(Query.property("title")) == ".\"title\"" + end + end + + describe "Mq.Query chaining" do + test "select with Filter" do + result = Query.h2() |> Query.select(Filter.contains("Feature")) |> to_string() + assert result == ".h2 | select(contains(\"Feature\"))" + end + + test "select with string filter" do + result = Query.h2() |> Query.select("contains(\"Feature\")") |> to_string() + assert result == ".h2 | select(contains(\"Feature\"))" + end + + test "standalone select (no leading selector)" do + assert to_string(Query.select(Filter.mdx?())) == "select(is_mdx())" + end + + test "output conversions" do + assert to_string(Query.h2() |> Query.to_text()) == ".h2 | to_text()" + assert to_string(Query.h2() |> Query.to_markdown()) == ".h2 | to_markdown()" + assert to_string(Query.text() |> Query.to_mdx()) == ".text | to_mdx()" + assert to_string(Query.text() |> Query.to_html()) == ".text | to_html()" + assert to_string(Query.text() |> Query.stringify()) == ".text | to_string()" + assert to_string(Query.text() |> Query.to_number()) == ".text | to_number()" + assert to_string(Query.text() |> Query.to_array()) == ".text | to_array()" + + assert to_string(Query.text() |> Query.to_markdown_string()) == + ".text | to_markdown_string()" + end + + test "chained attribute selectors" do + assert to_string(Query.link() |> Query.url()) == ".link | .url" + assert to_string(Query.code() |> Query.lang()) == ".code | .lang" + assert to_string(Query.code() |> Query.meta()) == ".code | .meta" + assert to_string(Query.code() |> Query.fence()) == ".code | .fence" + assert to_string(Query.image() |> Query.alt()) == ".image | .alt" + assert to_string(Query.link() |> Query.title()) == ".link | .title" + assert to_string(Query.link_ref() |> Query.ident()) == ".link_ref | .ident" + assert to_string(Query.link_ref() |> Query.label()) == ".link_ref | .label" + assert to_string(Query.heading() |> Query.depth()) == ".heading | .depth" + assert to_string(Query.heading() |> Query.level()) == ".heading | .level" + assert to_string(Query.list() |> Query.item_index()) == ".[] | .index" + assert to_string(Query.list() |> Query.ordered()) == ".[] | .ordered" + assert to_string(Query.task() |> Query.checked()) == ".task | .checked" + assert to_string(Query.table() |> Query.column()) == ".table | .column" + assert to_string(Query.table() |> Query.row()) == ".table | .row" + assert to_string(Query.table_align() |> Query.align()) == ".table_align | .align" + + assert to_string(Query.mdx_jsx_flow_element() |> Query.mdx_name()) == + ".mdx_jsx_flow_element | .name" + end + + test "property/2 chained" do + assert to_string(Query.text() |> Query.property("title")) == ".text | .\"title\"" + end + + test "string transformations" do + assert to_string(Query.text() |> Query.trim()) == ".text | trim()" + assert to_string(Query.text() |> Query.ltrim()) == ".text | ltrim()" + assert to_string(Query.text() |> Query.rtrim()) == ".text | rtrim()" + assert to_string(Query.text() |> Query.downcase()) == ".text | downcase()" + assert to_string(Query.text() |> Query.upcase()) == ".text | upcase()" + assert to_string(Query.text() |> Query.ascii_downcase()) == ".text | ascii_downcase()" + assert to_string(Query.text() |> Query.ascii_upcase()) == ".text | ascii_upcase()" + assert to_string(Query.text() |> Query.len()) == ".text | len()" + assert to_string(Query.text() |> Query.utf8bytelen()) == ".text | utf8bytelen()" + + assert to_string(Query.text() |> Query.gsub("foo", "bar")) == + ".text | gsub(\"foo\", \"bar\")" + + assert to_string(Query.text() |> Query.replace("old", "new")) == + ".text | replace(\"old\", \"new\")" + + assert to_string(Query.text() |> Query.split(",")) == ".text | split(\",\")" + assert to_string(Query.text() |> Query.repeat(3)) == ".text | repeat(3)" + assert to_string(Query.text() |> Query.slice(0, 5)) == ".text | slice(0, 5)" + assert to_string(Query.text() |> Query.index("foo")) == ".text | index(\"foo\")" + assert to_string(Query.text() |> Query.rindex("foo")) == ".text | rindex(\"foo\")" + end + + test "collection operations" do + assert to_string(Query.list() |> Query.length()) == ".[] | length" + assert to_string(Query.list() |> Query.add()) == ".[] | add" + assert to_string(Query.list() |> Query.first()) == ".[] | first" + assert to_string(Query.list() |> Query.last()) == ".[] | last" + assert to_string(Query.list() |> Query.empty()) == ".[] | empty" + assert to_string(Query.list() |> Query.reverse()) == ".[] | reverse" + assert to_string(Query.list() |> Query.sort()) == ".[] | sort" + assert to_string(Query.list() |> Query.compact()) == ".[] | compact" + assert to_string(Query.list() |> Query.uniq()) == ".[] | uniq" + assert to_string(Query.list() |> Query.flatten()) == ".[] | flatten" + assert to_string(Query.list() |> Query.keys()) == ".[] | keys" + assert to_string(Query.list() |> Query.values()) == ".[] | values" + assert to_string(Query.list() |> Query.entries()) == ".[] | entries" + assert to_string(Query.list() |> Query.children()) == ".[] | .children" + assert to_string(Query.h2() |> Query.nth(2)) == ".h2 | nth(2)" + assert to_string(Query.h2() |> Query.limit(5)) == ".h2 | limit(5)" + assert to_string(Query.h2() |> Query.range(3)) == ".h2 | range(3)" + assert to_string(Query.list() |> Query.join(", ")) == ".[] | join(\", \")" + assert to_string(Query.list() |> Query.del("item")) == ".[] | del(\"item\")" + assert to_string(Query.list() |> Query.insert(0, "new")) == ".[] | insert(0, \"new\")" + end + + test "math operations" do + assert to_string(Query.text() |> Query.abs()) == ".text | abs()" + assert to_string(Query.text() |> Query.ceil()) == ".text | ceil()" + assert to_string(Query.text() |> Query.floor()) == ".text | floor()" + assert to_string(Query.text() |> Query.round()) == ".text | round()" + assert to_string(Query.text() |> Query.trunc()) == ".text | trunc()" + assert to_string(Query.text() |> Query.sqrt()) == ".text | sqrt()" + assert to_string(Query.text() |> Query.pow(2)) == ".text | pow(2)" + assert to_string(Query.text() |> Query.min(0)) == ".text | min(0)" + assert to_string(Query.text() |> Query.max(100)) == ".text | max(100)" + assert to_string(Query.text() |> Query.negate()) == ".text | negate()" + end + + test "type/logic operations" do + assert to_string(Query.text() |> Query.type()) == ".text | type" + assert to_string(Query.text() |> Query.debug()) == ".text | debug" + + assert to_string(Query.text() |> Query.coalesce("default")) == + ".text | coalesce(\"default\")" + end + + test "encoding operations" do + assert to_string(Query.text() |> Query.base64()) == ".text | base64()" + assert to_string(Query.text() |> Query.base64d()) == ".text | base64d()" + assert to_string(Query.text() |> Query.md5()) == ".text | md5()" + assert to_string(Query.text() |> Query.sha256()) == ".text | sha256()" + assert to_string(Query.text() |> Query.to_hex()) == ".text | to_hex()" + assert to_string(Query.text() |> Query.from_hex()) == ".text | from_hex()" + end + + test "path operations" do + assert to_string(Query.text() |> Query.basename()) == ".text | basename()" + assert to_string(Query.text() |> Query.dirname()) == ".text | dirname()" + assert to_string(Query.text() |> Query.extname()) == ".text | extname()" + assert to_string(Query.text() |> Query.stem()) == ".text | stem()" + + assert to_string(Query.text() |> Query.path_join("file.md")) == + ".text | path_join(\"file.md\")" + end + + test "dict operations" do + assert to_string(Query.text() |> Query.get("key")) == ".text | get(\"key\")" + assert to_string(Query.text() |> Query.set("key", "val")) == ".text | set(\"key\", \"val\")" + end + + test "markdown mutation operations" do + assert to_string(Query.h2() |> Query.update("New Title")) == ".h2 | update(\"New Title\")" + assert to_string(Query.code() |> Query.attr("lang")) == ".code | attr(\"lang\")" + + assert to_string(Query.code() |> Query.set_attr("lang", "ruby")) == + ".code | set_attr(\"lang\", \"ruby\")" + + assert to_string(Query.link() |> Query.get_title()) == ".link | get_title" + assert to_string(Query.link() |> Query.get_url()) == ".link | get_url" + assert to_string(Query.task() |> Query.set_check(true)) == ".task | set_check(true)" + + assert to_string(Query.link_ref() |> Query.set_ref("myref")) == + ".link_ref | set_ref(\"myref\")" + + assert to_string(Query.code() |> Query.set_code_block_lang("ruby")) == + ".code | set_code_block_lang(\"ruby\")" + + assert to_string(Query.list() |> Query.set_list_ordered(true)) == + ".[] | set_list_ordered(true)" + end + + test "markdown construction" do + assert to_string(Query.text() |> Query.to_code("ruby")) == ".text | to_code(\"ruby\")" + assert to_string(Query.text() |> Query.to_code()) == ".text | to_code(null)" + assert to_string(Query.text() |> Query.to_code_inline()) == ".text | to_code_inline()" + assert to_string(Query.text() |> Query.to_h(2)) == ".text | to_h(2)" + assert to_string(Query.text() |> Query.to_hr()) == ".text | to_hr()" + assert to_string(Query.text() |> Query.to_strong()) == ".text | to_strong()" + assert to_string(Query.text() |> Query.to_em()) == ".text | to_em()" + assert to_string(Query.text() |> Query.to_math()) == ".text | to_math()" + assert to_string(Query.text() |> Query.to_math_inline()) == ".text | to_math_inline()" + assert to_string(Query.text() |> Query.to_md_text()) == ".text | to_md_text()" + assert to_string(Query.text() |> Query.to_md_list(0)) == ".text | to_md_list(0)" + + assert to_string(Query.text() |> Query.to_md_name("component")) == + ".text | to_md_name(\"component\")" + + assert to_string(Query.text() |> Query.to_md_table_row(["A", "B", "C"])) == + ".text | to_md_table_row(\"A\", \"B\", \"C\")" + + assert to_string(Query.text() |> Query.to_md_table_cell("content", 0, 1)) == + ".text | to_md_table_cell(\"content\", 0, 1)" + end + + test "to_link arities" do + assert to_string(Query.text() |> Query.to_link("https://example.com")) == + ".text | to_link(\"https://example.com\", \"\")" + + assert to_string(Query.text() |> Query.to_link("https://example.com", "Example")) == + ".text | to_link(\"https://example.com\", \"Example\", \"\")" + + assert to_string(Query.text() |> Query.to_link("https://example.com", "Example", "title")) == + ".text | to_link(\"https://example.com\", \"Example\", \"title\")" + end + + test "to_image arities" do + assert to_string(Query.text() |> Query.to_image("img.png")) == + ".text | to_image(\"img.png\", \"\")" + + assert to_string(Query.text() |> Query.to_image("img.png", "alt text")) == + ".text | to_image(\"img.png\", \"alt text\", \"\")" + + assert to_string(Query.text() |> Query.to_image("img.png", "alt text", "title")) == + ".text | to_image(\"img.png\", \"alt text\", \"title\")" + end + + test "multi-step chain" do + query = + Query.h2() + |> Query.select(Filter.contains("Section")) + |> Query.to_text() + + assert to_string(query) == ".h2 | select(contains(\"Section\")) | to_text()" + end + + test "complex chain with combined filters" do + query = + Query.h2() + |> Query.select( + Filter.contains("API") + |> Filter.and_filter(Filter.negate(Filter.contains("Internal"))) + ) + |> Query.to_text() + |> Query.downcase() + + assert to_string(query) == + ".h2 | select(contains(\"API\") && not(contains(\"Internal\"))) | to_text() | downcase()" + end + + test "to_query_string/1" do + assert Query.to_query_string(Query.h2()) == ".h2" + end + + test "Inspect protocol" do + assert inspect(Query.h2()) == "#Mq.Query<.h2>" + end + end + + describe "Mq.Query integration with Mq.run/3" do + test "accepts Query struct" do + content = "# Main\n\n## Features\n\n## Installation" + assert {:ok, result} = Mq.run(Query.h2(), content) + assert result.values == ["## Features", "## Installation"] + end + + test "filters with select via Query struct" do + content = "# Main\n\n## Features\n\n## Installation" + + assert {:ok, result} = + Mq.run(Query.h2() |> Query.select(Filter.contains("Feature")), content) + + assert result.values == ["## Features"] + end + + test "extracts code block language via chained attribute selector" do + md = "# Code\n\n```ruby\nputs 'hello'\n```" + assert {:ok, result} = Mq.run(Query.code() |> Query.lang(), md) + assert result.values == ["ruby"] + end + + test "extracts link URLs via chained attribute selector" do + md = "# Links\n\n[Google](https://google.com)\n\n[GitHub](https://github.com)" + assert {:ok, result} = Mq.run(Query.link() |> Query.url(), md) + assert result.values == ["https://google.com", "https://github.com"] + end + + test "applies downcase transformation" do + md = "# Hello World" + assert {:ok, result} = Mq.run(Query.h1() |> Query.to_text() |> Query.downcase(), md) + assert result.values == ["hello world"] + end + + test "filters with ends_with via Filter" do + md = "# Section A\n\n## Section B\n\n### Topic C" + assert {:ok, result} = Mq.run(Query.heading() |> Query.select(Filter.ends_with("B")), md) + assert result.values == ["## Section B"] + end + + test "combined AND filter" do + md = "# Section A\n\n## API Guide\n\n## Internal API\n\n## Installation" + + query = + Query.h2() + |> Query.select( + Filter.contains("API") + |> Filter.and_filter(Filter.negate(Filter.contains("Internal"))) + ) + + assert {:ok, result} = Mq.run(query, md) + assert result.values == ["## API Guide"] + end + + test "combined OR filter with Filter.any/1" do + md = "# Main\n\n## Features\n\n## Installation\n\n## Contributing" + + query = + Query.h2() + |> Query.select(Filter.any([Filter.contains("Feature"), Filter.contains("Install")])) + + assert {:ok, result} = Mq.run(query, md) + assert result.values == ["## Features", "## Installation"] + end + + test "plain string still works" do + content = "# Main\n\n## Features\n\n## Installation" + assert {:ok, result} = Mq.run(".h2", content) + assert result.values == ["## Features", "## Installation"] + end + end + + describe "Mq.Filter" do + test "string matching filters" do + assert to_string(Filter.contains("foo")) == "contains(\"foo\")" + assert to_string(Filter.starts_with("foo")) == "starts_with(\"foo\")" + assert to_string(Filter.ends_with("foo")) == "ends_with(\"foo\")" + end + + test "comparison filters" do + assert to_string(Filter.eq("foo")) == "eq(\"foo\")" + assert to_string(Filter.ne("foo")) == "ne(\"foo\")" + assert to_string(Filter.gt(5)) == "gt(5)" + assert to_string(Filter.gte(5)) == "gte(5)" + assert to_string(Filter.lt(5)) == "lt(5)" + assert to_string(Filter.lte(5)) == "lte(5)" + end + + test "type check filters" do + assert to_string(Filter.mdx?()) == "is_mdx()" + assert to_string(Filter.none?()) == "is_none()" + assert to_string(Filter.nan?()) == "is_nan()" + assert to_string(Filter.type()) == "type" + end + + test "and_filter/2 combines with AND" do + f = Filter.contains("foo") |> Filter.and_filter(Filter.starts_with("bar")) + assert to_string(f) == "contains(\"foo\") && starts_with(\"bar\")" + end + + test "or_filter/2 combines with OR" do + f = Filter.contains("foo") |> Filter.or_filter(Filter.contains("bar")) + assert to_string(f) == "contains(\"foo\") || contains(\"bar\")" + end + + test "all/1 combines list with AND" do + f = Filter.all([Filter.contains("A"), Filter.contains("B"), Filter.contains("C")]) + assert to_string(f) == "contains(\"A\") && contains(\"B\") && contains(\"C\")" + end + + test "any/1 combines list with OR" do + f = Filter.any([Filter.contains("A"), Filter.contains("B")]) + assert to_string(f) == "contains(\"A\") || contains(\"B\")" + end + + test "negate/1 wraps with not()" do + f = Filter.negate(Filter.contains("draft")) + assert to_string(f) == "not(contains(\"draft\"))" + end + + test "Inspect protocol" do + assert inspect(Filter.contains("foo")) == "#Mq.Filter" + end + + test "triple-combine with AND" do + f = + Filter.contains("API") + |> Filter.and_filter(Filter.negate(Filter.contains("Internal"))) + |> Filter.and_filter(Filter.starts_with("## ")) + + assert to_string(f) == + "contains(\"API\") && not(contains(\"Internal\")) && starts_with(\"## \")" + end + end end