diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1c22999 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +// Opens the project inside the compose `speller` container, so Ruby LSP (and +// its test explorer) run with the container's Ruby, gems and Aspell. +{ + "name": "speller", + "dockerComposeFile": "../compose.yaml", + "service": "speller", + "workspaceFolder": "/app", + // Keep the dev server running when VS Code closes; it is managed with + // `podman compose` as well. + "shutdownAction": "none", + "customizations": { + "vscode": { + "extensions": ["Shopify.ruby-lsp"] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ee89f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.bundle +.ruby-lsp +coverage diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..eb58934 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,54 @@ +name: testing + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + env: + # rerun, ruby-lsp and debug are only for the dev container + BUNDLE_WITHOUT: development + steps: + - name: Checkout code + uses: actions/checkout@v7 + + # ffi-aspell loads libaspell at runtime and the suite checks against the + # real es/en/pt_BR dictionaries, the same ones the Dockerfile installs. + - name: Install Aspell and dictionaries + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libaspell-dev aspell-en aspell-es aspell-pt-br + + # The Ruby version comes from .ruby-version + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests with coverage + env: + COVERAGE: '1' + run: bundle exec rake test + + - name: Coverage summary + run: | + echo "### Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "Line coverage: $(jq -r '.result.line' coverage/.last_run.json)%" >> "$GITHUB_STEP_SUMMARY" + + # TODO SonarQube: once the project exists, fix the paths in + # coverage/.resultset.json (sed $GITHUB_WORKSPACE -> /github/workspace/) + # and run LemontechSA/action-sonarqube-scan, as in green-mario. + - name: Upload coverage report + uses: actions/upload-artifact@v7 + with: + name: coverage + path: coverage/ + include-hidden-files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ad224d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +coverage/ +.DS_Store diff --git a/.ruby-version b/.ruby-version index b1b25a5..7919852 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.2.2 +4.0.9 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..3d91231 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + // Ruby LSP runs inside the dev container (.devcontainer/), not on the host + "recommendations": ["ms-vscode-remote.remote-containers"], + "unwantedRecommendations": ["Shopify.ruby-lsp"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f12d3..b445500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Release 26.0.0 +Wednesday, November 4th +* **Breaking** Actualiza a Ruby 4.0.9 y Sinatra 4.2.1 (Rack 3 + Puma). El servidor requiere Ruby 4.0 +* **Breaking** Las posiciones (`o`) apuntan a la palabra limpia, sin la puntuación inicial, y ya no se desfasan con espacios múltiples o saltos de línea: `texto[o, l]` es la palabra marcada +* **Breaking** Los nombres de headers de respuesta salen en minúscula (Rack 3) +* **Fix** Las sugerencias con tilde ya no salen corruptas y las palabras con tilde bien escritas ya no se marcan como error. El encoding de aspell ya no depende del locale +* **Fix** La puntuación repetida (`Bueno...`, `¡¡Hola!!`) ya no se marca como error +* **Update** Reemplaza `sinatra-cross_origin` por headers CORS propios con los mismos valores +* **New** Entorno de desarrollo con Podman compose en el puerto 3019 y Dev Container para Ruby LSP en VS Code +* **New** Suite de tests con Minitest +* **New** GitHub Action que corre los tests con cobertura (SimpleCov) en cada PR y en `master` + ## Hotfix 17.0.4 Monday, May 15th * **Fix** [Agrega método options](https://github.com/5rabbits/speller/pull/3) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91b7aaa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Single-file Sinatra service (`speller.rb`) that exposes Aspell spellchecking over HTTP. Ruby 4.0 (`.ruby-version`, enforced by the Gemfile and duplicated in the `Dockerfile` `FROM` line; keep both in sync), Sinatra 4.2 on Rack 3, Puma, `ffi-aspell` 1.1. + +## Commands + +Preferred: the Podman compose dev environment (`compose.yaml` + `Dockerfile`), which bundles Ruby, Aspell and the es/en/pt_BR dictionaries: + +``` +podman compose up --build -d # serves on localhost:3019 +podman compose logs -f +curl -X POST localhost:3019/spell -d 'lang=en' --data-urlencode 'text=Helo wrld' +podman compose run --rm speller bundle install # after editing Gemfile (writes Gemfile.lock), then up --build +podman compose run --rm speller irb -r ./speller # console with Speller loaded +``` + +The repo is bind-mounted at `/app` and `rerun` restarts `rackup` (Puma) when a source file changes, within a few seconds. It must use `--force-polling` because host file events don't reach the container through the podman machine mount. Gemfile changes need a rebuild. + +VS Code: `.devcontainer/devcontainer.json` attaches to the same compose container (`name: speller` in `compose.yaml` keeps Dev Containers from starting a second one on port 3019), and Ruby LSP runs there. `ruby-lsp` and `debug` are in the Gemfile's development group so the image already has them and Ruby LSP skips its composed bundle. Nothing Ruby-related is installed on the host. Dev Containers needs `"dev.containers.dockerPath": "podman"` in the user's VS Code settings. + +Without containers, Aspell plus dictionaries must be installed on the host (`ffi-aspell` loads the native library at runtime). See README. Then `bundle install && ruby speller.rb` (port 4567). + +Tests use Minitest + rack-test (`test/`) and need real Aspell dictionaries, so run them in the container: + +``` +podman compose run --rm speller bundle exec rake test # whole suite +podman compose run --rm speller bundle exec rake test TEST=test/speller_app_test.rb # one file +podman compose run --rm speller bundle exec ruby -Itest test/speller_test.rb -n test_check_returns_nil_without_text # one test +podman compose run --rm -e COVERAGE=1 speller bundle exec rake test # with SimpleCov report in coverage/ +``` + +Coverage is opt-in (`COVERAGE=1`, as in timeflow). SimpleCov 1.x starts at the top of `test/test_helper.rb`, before `speller.rb` is required, and only covers `speller.rb` (`cover`; 1.x deprecates `track_files`/`add_filter`). `coverage/` is gitignored. + +CI: `.github/workflows/testing.yml` runs on every PR and on push to `master`. It installs Aspell and the dictionaries with apt, uses `ruby/setup-ruby` (version from `.ruby-version`), runs the suite with `COVERAGE=1`, writes line coverage to the job summary and uploads `coverage/` as an artifact (with `.resultset.json`). SonarQube isn't set up yet because the project doesn't exist; the workflow has a TODO where the scan goes. + +There is no linter config. + +## Architecture + +`speller.rb` holds two classes. `SpellerApp.run!` only fires when the file is executed directly (port 4567, or `PORT`); `config.ru` mounts `SpellerApp` for `rackup`, and requiring `speller.rb` does not boot a server. + +- `SpellerApp` (modular `Sinatra::Base`): `POST /spell` with `text` and `lang` params (form body or query string), plus `OPTIONS /spell` for CORS preflight. There is no GET route. CORS is hand-rolled in a `before` filter: when a request carries `Origin`, it is echoed back along with `CORS_HEADERS`. These values match what the old `sinatra-cross_origin` gem sent, and `test_options_answers_cors_preflight` pins them. +- **Host authorization (Sinatra 4.1+):** by default, Sinatra 403s requests in `development` whose `Host` isn't `localhost`, `*.localhost`, `*.test` or an IP. `SpellerApp` turns that off (`permitted_hosts: []` in `configure :development`), so any host is accepted in every environment. This is a deliberate, temporary choice to revisit later. The tests run as `test`, so they don't cover this setting. +- `Speller.check(text, lang)`: builds a fresh `FFI::Aspell::Speller` per request. `Speller.words` scans whitespace-separated tokens, strips all leading and trailing punctuation (punctuation-only tokens are skipped), and keeps each cleaned word's offset. `check` returns only misspelled words. Returns `nil` (JSON `null`) when `text` is missing. + +Behavior that is easy to break: + +- **Languages:** only `es`, `en`, `pt` are accepted; anything else silently falls back to `es`. `pt` maps to the `pt_BR` Aspell dictionary. +- **Response shape:** `{ attrs: { l:, o:, s: 1 }, suggestions: [...] }`, at most 10 suggestions. `o` is the character offset (not byte) of the cleaned word in the original text and `l` is its length, so `text[o, l]` is exactly the flagged word, whatever whitespace or punctuation surrounds it. `s` is always `1`. The `flagged` helper in `test/speller_test.rb` asserts this. Clients rely on this shape. +- **Encoding:** `Speller.check` creates the speller with `'encoding' => 'utf-8'`, so input and suggestions are UTF-8 whatever the locale or `ASPELL_CONF` say. The option must stay ahead of the language: ffi-aspell applies options in hash order, and with no locale the default encoding is `none`, which raises `unknown encoding name - none`. Don't bring back the old `force_encoding('ISO-8859-1')` workaround (hotfixes 17.0.2/17.0.3). It garbled accented suggestions (`corazón`) or flagged correct accented words, depending on the environment. The accented-text tests in `test/speller_test.rb` cover both cases. + +## Releases + +Versions use `YY.minor.patch` (e.g. `17.0.4`) in `VERSION`. Hotfixes go on `hotfix/` branches, bump `VERSION`, and add an entry to `CHANGELOG.md` (date plus a `**Fix**` line linking the PR). Changelog entries and older commits are often in Spanish. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1556ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Development image for the speller service. +# Keep the Ruby version in sync with .ruby-version (the Gemfile enforces it). +FROM docker.io/library/ruby:4.0.9-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + libaspell-dev \ + aspell-en \ + aspell-es \ + aspell-pt-br \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Gemfile Gemfile.lock .ruby-version ./ +RUN bundle install + +COPY . . + +EXPOSE 3019 + +# rerun restarts the server on source changes. Polling is required: file events +# from the host do not reach the container through the podman machine mount. +CMD ["bundle", "exec", "rerun", "--background", "--force-polling", "--", \ + "rackup", "--host", "0.0.0.0", "--port", "3019"] diff --git a/Gemfile b/Gemfile index e75e865..ad71395 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,31 @@ source 'https://rubygems.org' + +ruby file: '.ruby-version' + # Classy web-development dressed in a DSL -gem 'sinatra' +gem 'sinatra', '~> 4.2' + +# Rack 3 moved the server launcher (rackup, SpellerApp.run!) out of rack +gem 'rackup', '~> 2.3' +gem 'puma', '~> 8.0' + +# Load aspell dynamically for spellcheck +gem 'ffi-aspell', '~> 1.1' -# Load aspell dinamically for spellcheck -gem 'ffi-aspell', '1.0.2' +group :development do + # Restarts the server when source files change + gem 'rerun', '~> 0.14' + # Language server and debugger for the VS Code Ruby LSP extension (run in the + # container). Kept here so the image has them and Ruby LSP installs nothing. + gem 'ruby-lsp', '~> 0.26', require: false + gem 'debug', '~> 1.11', require: false +end -# Cross Origin Request Sharing extension for Sinatra -gem 'sinatra-cross_origin', '0.3.2' +group :test do + gem 'minitest', '~> 6.0' + gem 'rake', '~> 13.4' + # Drives the Sinatra app in request tests + gem 'rack-test', '~> 2.3' + # Coverage report, opt-in with COVERAGE=1 (see test/test_helper.rb) + gem 'simplecov', '~> 1.3', require: false +end diff --git a/Gemfile.lock b/Gemfile.lock index 97402fc..02bcad7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,26 +1,167 @@ GEM remote: https://rubygems.org/ specs: - ffi (1.9.10) - ffi-aspell (1.0.2) + base64 (0.3.0) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + erb (6.0.7) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + ffi-aspell (1.1.0) ffi - rack (1.6.4) - rack-protection (1.5.3) - rack - sinatra (1.4.7) - rack (~> 1.5) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) - sinatra-cross_origin (0.3.2) - tilt (2.0.5) + io-console (0.9.5) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + language_server-protocol (3.17.0.6) + listen (3.10.1) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + mustermann (3.1.1) + nio4r (2.7.5) + pp (0.6.4) + prettyprint + prettyprint (0.2.1) + prism (1.9.0) + puma (8.0.2) + nio4r (~> 2.0) + rack (3.2.8) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.3.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) + rerun (0.14.0) + listen (~> 3.0) + ruby-lsp (0.26.11) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + simplecov (1.3.2) + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + tilt (2.9.0) + tsort (0.2.0) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - ffi-aspell (= 1.0.2) - sinatra - sinatra-cross_origin (= 0.3.2) + debug (~> 1.11) + ffi-aspell (~> 1.1) + minitest (~> 6.0) + puma (~> 8.0) + rack-test (~> 2.3) + rackup (~> 2.3) + rake (~> 13.4) + rerun (~> 0.14) + ruby-lsp (~> 0.26) + simplecov (~> 1.3) + sinatra (~> 4.2) + +CHECKSUMS + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf + ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 + ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + ffi-aspell (1.1.0) sha256=000a29cd47076154229361d0b2fc2bbd223932c8c273a0ff7ee8cf1c8e4e54b6 + io-console (0.9.5) sha256=83dd9bbf4c4680416629245b39cd4212eb5f4d9a4ea91804870c1f6b846414d5 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + listen (3.10.1) sha256=4c3f32855657f348747da4dd2372ff3790d90a6b1d3e4f21a3706adb2d04783c + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + rack (3.2.8) sha256=1fecc85e463ebc7a2a945fff829ffcd549babeb1556384796fad946c28dc4ec7 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.3.0) sha256=b118d177cc002c572fc1b6fd8d19c9ed5881a36e8d6f689f9e618f9f861241e8 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd + ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d + simplecov (1.3.2) sha256=2a22ec1759f1b8db60881a2e038758956fc69ef93ed669be0bde0678de6d0e85 + sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 + tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + +RUBY VERSION + ruby 4.0.9 BUNDLED WITH - 1.12.5 + 4.0.20 diff --git a/README.md b/README.md index 87a1d77..2de737a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,47 @@ bundle install ruby speller.rb ``` +## Development with Podman + +The compose setup includes Ruby 4.0, Aspell and the es/en/pt_BR +dictionaries, so nothing needs to be installed on the host besides Podman. + +``` +podman compose up --build -d +curl -X POST localhost:3019/spell -d lang=en --data-urlencode 'text=Helo wrld' +``` + +The source is mounted into the container and the server restarts a few +seconds after a source file changes, so edits to `speller.rb` apply without a +manual restart. After changing the +`Gemfile`, run `podman compose run --rm speller bundle install` to update +`Gemfile.lock`, then `podman compose up --build -d`. + +### VS Code + +Ruby LSP runs inside the container, so no Ruby is needed on the host: + +1. Install the Dev Containers extension (`ms-vscode-remote.remote-containers`). +2. Add `"dev.containers.dockerPath": "podman"` to your VS Code user settings. +3. Run **Dev Containers: Reopen in Container**. + +## Tests + +The suite uses Minitest and runs against the real Aspell dictionaries: + +``` +podman compose run --rm speller bundle exec rake test +``` + +To get a coverage report in `coverage/index.html`, set `COVERAGE=1`: + +``` +podman compose run --rm -e COVERAGE=1 speller bundle exec rake test +``` + +GitHub Actions runs the suite with coverage on every pull request and on +pushes to `master` (`.github/workflows/testing.yml`). + ## Endpoints To consume the service, you could post ```[ROOT_URL]/spell``` endpoint, with the following params: diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d441390 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.pattern = 'test/**/*_test.rb' + t.warning = false +end + +task default: :test diff --git a/VERSION b/VERSION index ea2195e..1e212a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.0.4 +26.0.0 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bee5f2b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,18 @@ +# Development environment. The source tree is bind-mounted and rerun restarts +# the server when a source file changes, so edits apply without a manual restart. +# Gemfile changes need a rebuild: podman compose up --build +# +# The fixed project name lets the Dev Container (.devcontainer/) attach to the +# same container instead of starting a second one that would clash on 3019. +name: speller + +services: + speller: + build: . + image: localhost/speller:dev + ports: + - "3019:3019" + environment: + RACK_ENV: development + volumes: + - .:/app diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..ce329ab --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +require_relative 'speller' + +run SpellerApp diff --git a/speller.rb b/speller.rb index 518460a..a247126 100644 --- a/speller.rb +++ b/speller.rb @@ -1,16 +1,34 @@ require 'sinatra/base' -require 'sinatra/cross_origin' require 'ffi/aspell' require 'json' # Sinatra app for check spelling class SpellerApp < Sinatra::Base - register Sinatra::CrossOrigin + # Sent with every request that carries an Origin, which is echoed back so + # any site can call the service. + CORS_HEADERS = { + 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers' => '*, Content-Type, Accept, AUTHORIZATION, Cache-Control', + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Max-Age' => '1728000', + 'Access-Control-Expose-Headers' => + 'Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma' + }.freeze configure do - set :allow_origin, :any set :bind, '0.0.0.0' - enable :cross_origin + end + + # Sinatra 4.1+ rejects unknown Host headers in development (e.g. another + # container calling http://speller:3019). Disabled for now; other + # environments already accept any host. + configure :development do + set :host_authorization, permitted_hosts: [] + end + + before do + origin = request.env['HTTP_ORIGIN'] + headers CORS_HEADERS.merge('Access-Control-Allow-Origin' => origin) if origin end post '/spell' do @@ -24,18 +42,35 @@ class SpellerApp < Sinatra::Base # A class used to use aspell to spellcheck a text class Speller - def self.check(words, lang, max_suggestions = 10) - return if words.nil? + LEADING_PUNCTUATION = /\A[[:punct:]]*/ + TRAILING_PUNCTUATION = /[[:punct:]]*\z/ + + def self.check(text, lang, max_suggestions = 10) + return if text.nil? lang = ensure_lang(lang) - speller = FFI::Aspell::Speller.new(lang) - - pos = 0 - words.split.map do |word| - element = correct_word(speller, clean_word(word), max_suggestions, pos) - pos += (word.length + 1) - element - end.compact + # Pin aspell to UTF-8 for input and output instead of inheriting it from + # the process locale. It must be set before the language: ffi-aspell + # applies options in hash order, and with no locale the default encoding + # is "none", which crashes ffi-aspell. + speller = FFI::Aspell::Speller.new(lang, 'encoding' => 'utf-8') + + words(text).filter_map do |word, pos| + correct_word(speller, word, max_suggestions, pos) + end + end + + # Each whitespace-separated token of text with its surrounding punctuation + # removed, paired with the character offset where that cleaned word starts, + # so text[pos, word.length] == word. Punctuation-only tokens are skipped. + def self.words(text) + text.enum_for(:scan, /\S+/).filter_map do + match = Regexp.last_match + word = clean_word(match[0]) + next if word.empty? + + [word, match.begin(0) + match[0][LEADING_PUNCTUATION].length] + end end def self.ensure_lang(lang) @@ -45,17 +80,13 @@ def self.ensure_lang(lang) end def self.clean_word(word) - word.gsub(/^[[:punct:]]/,'') - .gsub(/[[:punct:]]$/,'') + word.sub(LEADING_PUNCTUATION, '').sub(TRAILING_PUNCTUATION, '') end def self.correct_word(speller, word, max_suggestions, pos) return if speller.correct?(word) suggestions = speller.suggestions(word).first(max_suggestions) - suggestions.map! do |suggestion| - suggestion.force_encoding('ISO-8859-1').encode('UTF-8') - end suggestions_to_hash(word, pos, suggestions) end @@ -64,4 +95,4 @@ def self.suggestions_to_hash(word, pos, suggestions) end end -SpellerApp.run! +SpellerApp.run! if __FILE__ == $PROGRAM_NAME diff --git a/test/speller_app_test.rb b/test/speller_app_test.rb new file mode 100644 index 0000000..28632c2 --- /dev/null +++ b/test/speller_app_test.rb @@ -0,0 +1,86 @@ +require 'test_helper' + +class SpellerAppTest < Minitest::Test + include Rack::Test::Methods + + def app + SpellerApp + end + + def json_body + JSON.parse(last_response.body) + end + + def test_post_returns_misspelled_words_as_json + post '/spell', text: 'Helo world', lang: 'en' + + assert last_response.ok? + assert_equal 1, json_body.size + assert_equal({ 'l' => 4, 'o' => 0, 's' => 1 }, json_body.first['attrs']) + assert_includes json_body.first['suggestions'], 'Hello' + end + + def test_post_accepts_params_in_query_string + post '/spell?lang=en&text=Helo' + + assert_includes json_body.first['suggestions'], 'Hello' + end + + def test_post_handles_accented_form_input + post '/spell', text: 'El corazón late, corazon', lang: 'es' + + assert_equal 1, json_body.size + assert_equal({ 'l' => 7, 'o' => 17, 's' => 1 }, json_body.first['attrs']) + assert_includes json_body.first['suggestions'], 'corazón' + end + + def test_post_offsets_count_newlines_in_form_input + post '/spell', text: "Hola\n\nprueva", lang: 'es' + + assert_equal({ 'l' => 6, 'o' => 6, 's' => 1 }, json_body.first['attrs']) + end + + def test_post_returns_null_without_text + post '/spell', lang: 'es' + + assert last_response.ok? + assert_equal 'null', last_response.body + end + + def test_post_allows_any_origin + post '/spell', { text: 'hola', lang: 'es' }, 'HTTP_ORIGIN' => 'http://example.com' + + assert_equal 'http://example.com', last_response.headers['Access-Control-Allow-Origin'] + end + + def test_options_answers_cors_preflight + options '/spell', {}, 'HTTP_ORIGIN' => 'http://example.com' + + assert last_response.ok? + assert_empty last_response.body + expected = { + 'Access-Control-Allow-Origin' => 'http://example.com', + 'Access-Control-Allow-Methods' => 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers' => '*, Content-Type, Accept, AUTHORIZATION, Cache-Control', + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Max-Age' => '1728000', + 'Access-Control-Expose-Headers' => + 'Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma' + } + expected.each do |name, value| + assert_equal value, last_response.headers[name], name + end + end + + def test_no_cors_headers_without_origin + post '/spell', text: 'hola', lang: 'es' + + assert_nil last_response.headers['Access-Control-Allow-Origin'] + end + + def test_get_is_not_routed + get '/spell', text: 'hola', lang: 'es' + + assert_equal 404, last_response.status + end +end diff --git a/test/speller_test.rb b/test/speller_test.rb new file mode 100644 index 0000000..40418bb --- /dev/null +++ b/test/speller_test.rb @@ -0,0 +1,146 @@ +require 'test_helper' + +class SpellerTest < Minitest::Test + def test_ensure_lang_keeps_supported_languages + assert_equal 'es', Speller.ensure_lang('es') + assert_equal 'en', Speller.ensure_lang('en') + end + + def test_ensure_lang_maps_pt_to_pt_br_dictionary + assert_equal 'pt_BR', Speller.ensure_lang('pt') + end + + def test_ensure_lang_falls_back_to_es + assert_equal 'es', Speller.ensure_lang('fr') + assert_equal 'es', Speller.ensure_lang(nil) + end + + def test_clean_word_strips_leading_and_trailing_punctuation + assert_equal 'hola', Speller.clean_word('hola,') + assert_equal 'qué', Speller.clean_word('¿qué?') + assert_equal 'hola', Speller.clean_word('(hola)') + end + + def test_clean_word_keeps_inner_punctuation + assert_equal "don't", Speller.clean_word("don't") + end + + def test_clean_word_strips_repeated_punctuation + assert_equal 'hola', Speller.clean_word('hola...') + assert_equal 'hola', Speller.clean_word('¡¡hola!!') + assert_equal 'qué', Speller.clean_word('"¿qué?"') + end + + def test_clean_word_returns_empty_for_punctuation_only + assert_equal '', Speller.clean_word('...') + end + + def test_check_returns_nil_without_text + assert_nil Speller.check(nil, 'es') + end + + def test_check_returns_empty_list_when_every_word_is_correct + assert_equal [], Speller.check('El perro come pan', 'es') + end + + def test_check_reports_misspelled_word_with_position_and_suggestions + result = Speller.check('Esto es una prueva', 'es') + + assert_equal 1, result.size + assert_equal({ l: 6, o: 12, s: 1 }, result.first[:attrs]) + assert_includes result.first[:suggestions], 'prueba' + end + + def test_check_offsets_point_at_cleaned_word + result = Speller.check('Hola ¿cmo estás?', 'es') + + assert_equal 1, result.size + assert_equal({ l: 3, o: 6, s: 1 }, result.first[:attrs]) + assert_includes result.first[:suggestions], 'como' + end + + def test_check_offsets_survive_repeated_whitespace + text = "Hola mundo\n\nprueva\tcmo" + + assert_equal [13, 20], offsets(text) + assert_equal %w[prueva cmo], flagged(text) + end + + def test_check_offsets_survive_leading_whitespace + assert_equal [2], offsets(' prueva') + end + + def test_check_offsets_skip_surrounding_punctuation + assert_equal ['prueva'], flagged('Dijo: «prueva»,') + end + + def test_check_ignores_tokens_that_are_only_punctuation + assert_equal [], Speller.check('Hola - mundo ...', 'es') + end + + def test_check_ignores_repeated_punctuation + assert_equal [], Speller.check('Bueno... sí', 'es') + assert_equal [], Speller.check('¡¡Hola!!', 'es') + end + + def test_check_returns_empty_list_for_blank_text + assert_equal [], Speller.check('', 'es') + assert_equal [], Speller.check(" \n", 'es') + end + + def test_check_returns_ten_suggestions_by_default + assert_equal 10, Speller.check('prueva', 'es').first[:suggestions].size + end + + def test_check_accepts_custom_suggestion_limit + assert_equal 3, Speller.check('prueva', 'es', 3).first[:suggestions].size + end + + def test_check_uses_each_language_dictionary + assert_equal [], Speller.check('Hello world', 'en') + assert_equal [], Speller.check('Olá mundo', 'pt') + refute_empty Speller.check('Hello', 'es') + end + + def test_check_falls_back_to_spanish_for_unknown_languages + assert_includes Speller.check('prueva', 'fr').first[:suggestions], 'prueba' + end + + def test_check_accepts_correctly_spelled_accented_words + assert_equal [], Speller.check('El corazón late con pasión', 'es') + assert_equal [], Speller.check('O coração é um órgão', 'pt') + end + + def test_check_returns_accented_suggestions_as_valid_utf8 + spanish = Speller.check('corazon', 'es').first[:suggestions] + portuguese = Speller.check('coracao', 'pt').first[:suggestions] + + assert_includes spanish, 'corazón' + assert_includes portuguese, 'coração' + (spanish + portuguese).each do |suggestion| + assert_equal Encoding::UTF_8, suggestion.encoding + assert suggestion.valid_encoding?, "#{suggestion.inspect} is not valid UTF-8" + end + end + + def test_check_ignores_encoding_from_aspell_environment + previous = ENV['ASPELL_CONF'] + ENV['ASPELL_CONF'] = 'encoding iso-8859-1' + + assert_equal [], Speller.check('corazón', 'es') + assert_includes Speller.check('corazon', 'es').first[:suggestions], 'corazón' + ensure + ENV['ASPELL_CONF'] = previous + end + + private + + def offsets(text, lang = 'es') + Speller.check(text, lang).map { |entry| entry[:attrs][:o] } + end + + # The slice of text each result points at, as a client would highlight it + def flagged(text, lang = 'es') + Speller.check(text, lang).map { |entry| text[entry[:attrs][:o], entry[:attrs][:l]] } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..0024eec --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,17 @@ +# Coverage is opt-in (COVERAGE=1), as in the other Lemontech services. CI turns +# it on and keeps coverage/.resultset.json, the file SonarQube will read. +# SimpleCov must start before the app is required, or speller.rb isn't tracked. +if ENV['COVERAGE'] + require 'simplecov' + + SimpleCov.start do + cover 'speller.rb' + end +end + +ENV['RACK_ENV'] = 'test' + +require 'minitest/autorun' +require 'json' +require 'rack/test' +require_relative '../speller'