├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE_REQUEST.md │ └── BUG_REPORT.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── lib ├── strings-truncation.rb └── strings │ ├── truncation │ ├── version.rb │ ├── extensions.rb │ └── configuration.rb │ └── truncation.rb ├── .rspec ├── bin ├── setup └── console ├── Rakefile ├── .gitignore ├── CHANGELOG.md ├── .editorconfig ├── tasks ├── coverage.rake ├── console.rake └── spec.rake ├── Gemfile ├── spec ├── unit │ ├── extensions_spec.rb │ ├── configure_spec.rb │ ├── truncate_multibyte_spec.rb │ ├── truncate_ansi_spec.rb │ └── truncate_spec.rb ├── spec_helper.rb └── perf │ └── truncate_spec.rb ├── appveyor.yml ├── .rubocop.yml ├── LICENSE.txt ├── strings-truncation.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: piotrmurach 2 | -------------------------------------------------------------------------------- /lib/strings-truncation.rb: -------------------------------------------------------------------------------- 1 | require "strings/truncation" 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | FileList["tasks/**/*.rake"].each(&method(:import)) 4 | 5 | desc "Run all specs" 6 | task ci: %w[ spec ] 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /lib/strings/truncation/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strings 4 | class Truncation 5 | VERSION = "0.1.0" 6 | end # Truncation 7 | end # Strings 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## [v0.1.0] - 2021-02-23 4 | 5 | * Initial implementation and release 6 | 7 | [v0.1.0]: https://github.com/piotrmurach/strings-truncation/compare/efb1d50...v0.1.0 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.rb] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Strings Community Discussions 4 | url: https://github.com/piotrmurach/strings/discussions 5 | about: Suggest ideas, ask and answer questions 6 | -------------------------------------------------------------------------------- /tasks/coverage.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Measure code coverage" 4 | task :coverage do 5 | begin 6 | original, ENV["COVERAGE"] = ENV["COVERAGE"], "true" 7 | Rake::Task["spec"].invoke 8 | ensure 9 | ENV["COVERAGE"] = original 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /tasks/console.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Load gem inside irb console" 4 | task :console do 5 | require "irb" 6 | require "irb/completion" 7 | require File.join(__FILE__, "../../lib/strings-truncation") 8 | ARGV.clear 9 | IRB.start 10 | end 11 | task c: %w[ console ] 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "strings/truncation" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/strings/truncation/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../truncation" 4 | 5 | module Strings 6 | class Truncation 7 | module Extensions 8 | refine String do 9 | def truncate(*args, **options) 10 | Strings::Truncation.truncate(self, *args, **options) 11 | end 12 | end 13 | end # Extensions 14 | end # Truncation 15 | end # Strings 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest new functionality 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ### Describe the problem 10 | 11 | A brief description of the problem you're trying to solve. 12 | 13 | ### How would the new feature work? 14 | 15 | A short explanation of the new feature. 16 | 17 | ``` 18 | Example code that shows possible usage 19 | ``` 20 | 21 | ### Drawbacks 22 | 23 | Can you see any potential drawbacks? 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "activesupport" 7 | gem "benchmark-ips", "~> 2.7.2" 8 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.5.0") 9 | gem "coveralls_reborn", "~> 0.22.0" 10 | gem "simplecov", "~> 0.21.0" 11 | end 12 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.1.0") 13 | gem "rspec-benchmark", "~> 0.6" 14 | end 15 | gem "json", "2.4.1" if RUBY_VERSION == "2.0.0" 16 | end 17 | 18 | group :metrics do 19 | gem "yardstick", "~> 0.9.9" 20 | end 21 | -------------------------------------------------------------------------------- /spec/unit/extensions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "strings/truncation/extensions" 4 | 5 | using Strings::Truncation::Extensions 6 | 7 | RSpec.describe Strings::Truncation::Extensions do 8 | it "truncates a string to a given length" do 9 | expect("I try all things, I achieve what I can.".truncate(15)) 10 | .to eq("I try all thin…") 11 | end 12 | 13 | it "truncates a string based on separator" do 14 | expect( 15 | "I try all things, I achieve what I can.".truncate(15, separator: " ") 16 | ).to eq("I try all…") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Describe the change 2 | 3 | What does this Pull Request do? 4 | 5 | ### Why are we doing this? 6 | 7 | Any related context as to why is this is a desirable change. 8 | 9 | ### Benefits 10 | 11 | How will the library improve? 12 | 13 | ### Drawbacks 14 | 15 | Possible drawbacks applying this change. 16 | 17 | ### Requirements 18 | 19 | - [ ] Tests written & passing locally? 20 | - [ ] Code style checked? 21 | - [ ] Rebased with `master` branch? 22 | - [ ] Documentation updated? 23 | - [ ] Changelog updated? 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report something not working correctly or as expected 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ### Describe the problem 10 | 11 | A brief description of the issue. 12 | 13 | ### Steps to reproduce the problem 14 | 15 | ``` 16 | Your code here to reproduce the issue 17 | ``` 18 | 19 | ### Actual behaviour 20 | 21 | What happened? This could be a description, log output, error raised etc. 22 | 23 | ### Expected behaviour 24 | 25 | What did you expect to happen? 26 | 27 | ### Describe your environment 28 | 29 | * OS version: 30 | * Ruby version: 31 | * Strings::Truncation version: 32 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | --- 2 | skip_commits: 3 | files: 4 | - "bin/**" 5 | - "*.md" 6 | install: 7 | - SET PATH=C:\Ruby%ruby_version%\bin;%PATH% 8 | - gem install bundler -v '< 2.0' 9 | - bundle install 10 | before_test: 11 | - ruby -v 12 | - gem -v 13 | - bundle -v 14 | build: off 15 | test_script: 16 | - bundle exec rake ci 17 | environment: 18 | matrix: 19 | - ruby_version: "200" 20 | - ruby_version: "200-x64" 21 | - ruby_version: "21" 22 | - ruby_version: "21-x64" 23 | - ruby_version: "22" 24 | - ruby_version: "22-x64" 25 | - ruby_version: "23" 26 | - ruby_version: "23-x64" 27 | - ruby_version: "24" 28 | - ruby_version: "24-x64" 29 | - ruby_version: "25" 30 | - ruby_version: "25-x64" 31 | - ruby_version: "26" 32 | - ruby_version: "26-x64" 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV["COVERAGE"] == "true" 4 | require "simplecov" 5 | require "coveralls" 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | Coveralls::SimpleCov::Formatter 10 | ]) 11 | 12 | SimpleCov.start do 13 | command_name "spec" 14 | add_filter "spec" 15 | end 16 | end 17 | 18 | require "bundler/setup" 19 | require "strings/truncation" 20 | 21 | RSpec.configure do |config| 22 | # Enable flags like --only-failures and --next-failure 23 | config.example_status_persistence_file_path = ".rspec_status" 24 | 25 | # Disable RSpec exposing methods globally on `Module` and `main` 26 | config.disable_monkey_patching! 27 | 28 | config.expect_with :rspec do |c| 29 | c.syntax = :expect 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /tasks/spec.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "rspec/core/rake_task" 5 | 6 | desc "Run all specs" 7 | RSpec::Core::RakeTask.new(:spec) do |task| 8 | task.pattern = "spec/{unit,integration}{,/*/**}/*_spec.rb" 9 | end 10 | 11 | namespace :spec do 12 | desc "Run unit specs" 13 | RSpec::Core::RakeTask.new(:unit) do |task| 14 | task.pattern = "spec/unit{,/*/**}/*_spec.rb" 15 | end 16 | 17 | desc "Run integration specs" 18 | RSpec::Core::RakeTask.new(:integration) do |task| 19 | task.pattern = "spec/integration{,/*/**}/*_spec.rb" 20 | end 21 | 22 | desc "Run performance specs" 23 | RSpec::Core::RakeTask.new(:perf) do |task| 24 | task.pattern = "spec/perf{,/*/**}/*_spec.rb" 25 | end 26 | end 27 | 28 | rescue LoadError 29 | %w[spec spec:unit spec:integration].each do |name| 30 | task name do 31 | $stderr.puts "In order to run #{name}, do `gem install rspec`" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | TargetRubyVersion: 2.0 4 | 5 | Layout/FirstArrayElementIndentation: 6 | Enabled: false 7 | 8 | Layout/LineLength: 9 | Max: 80 10 | 11 | Lint/AssignmentInCondition: 12 | Enabled: false 13 | 14 | Metrics/AbcSize: 15 | Max: 35 16 | 17 | Metrics/BlockLength: 18 | CountComments: true 19 | Max: 32 20 | IgnoredMethods: [] 21 | Exclude: 22 | - "spec/**/*" 23 | 24 | Metrics/ClassLength: 25 | Max: 1500 26 | 27 | Metrics/CyclomaticComplexity: 28 | Enabled: false 29 | 30 | Metrics/MethodLength: 31 | Max: 20 32 | 33 | Naming/BinaryOperatorParameterName: 34 | Enabled: false 35 | 36 | Style/AccessorGrouping: 37 | Enabled: false 38 | 39 | Style/AsciiComments: 40 | Enabled: false 41 | 42 | Style/LambdaCall: 43 | EnforcedStyle: braces 44 | 45 | Style/FormatString: 46 | EnforcedStyle: percent 47 | 48 | Style/StringLiterals: 49 | EnforcedStyle: double_quotes 50 | 51 | Style/TrivialAccessors: 52 | Enabled: false 53 | 54 | # { ... } for multi-line blocks is okay 55 | Style/BlockDelimiters: 56 | Enabled: false 57 | 58 | Style/CommentedKeyword: 59 | Enabled: false 60 | -------------------------------------------------------------------------------- /spec/perf/truncate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec-benchmark" 4 | require "active_support/core_ext/string/filters" 5 | 6 | RSpec.describe Strings::Truncation do 7 | include RSpec::Benchmark::Matchers 8 | 9 | it "truncates text slower than ActiveSupport" do 10 | text = "It is not down on any map; true places never are." 11 | expect { 12 | Strings::Truncation.truncate(text, 20) 13 | }.to perform_slower_than { 14 | text.truncate(20) 15 | }.at_most(135).times 16 | end 17 | 18 | it "truncates text with separator slower than ActiveSupport" do 19 | text = "It is not down on any map; true places never are." 20 | separator = /\s/ 21 | expect { 22 | Strings::Truncation.truncate(text, 20, separator: separator) 23 | }.to perform_slower_than { 24 | text.truncate(20, separator: separator) 25 | }.at_most(55).times 26 | end 27 | 28 | it "allocates no more than 95 objects" do 29 | text = "It is not down on any map; true places never are." 30 | expect { 31 | Strings::Truncation.truncate(text, 20) 32 | }.to perform_allocation(95).objects 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Piotr Murach (piotrmurach.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - "bin/**" 9 | - "*.md" 10 | pull_request: 11 | branches: 12 | - master 13 | paths-ignore: 14 | - "bin/**" 15 | - "*.md" 16 | jobs: 17 | tests: 18 | name: Ruby ${{ matrix.ruby }} 19 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | ruby: 24 | - "2.0" 25 | - "2.1" 26 | - "2.3" 27 | - "2.4" 28 | - "2.5" 29 | - "2.6" 30 | - "3.0" 31 | - "3.1" 32 | - "3.2" 33 | - "3.3" 34 | - ruby-head 35 | - jruby-9.2 36 | - jruby-9.3 37 | - jruby-9.4 38 | - jruby-head 39 | - truffleruby-head 40 | include: 41 | - ruby: "2.2" 42 | os: ubuntu-20.04 43 | - ruby: "2.7" 44 | coverage: true 45 | env: 46 | COVERAGE: ${{ matrix.coverage }} 47 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 48 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Set up Ruby 52 | uses: ruby/setup-ruby@v1 53 | with: 54 | ruby-version: ${{ matrix.ruby }} 55 | bundler-cache: true 56 | - name: Run tests 57 | run: bundle exec rake ci 58 | -------------------------------------------------------------------------------- /strings-truncation.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/strings/truncation/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "strings-truncation" 7 | spec.version = Strings::Truncation::VERSION 8 | spec.authors = ["Piotr Murach"] 9 | spec.email = ["piotr@piotrmurach.com"] 10 | spec.summary = "Truncate strings with fullwidth characters and ANSI codes." 11 | spec.description = "Truncate strings with fullwidth characters and ANSI codes. Characters can be omitted from the start, middle, end or both ends." 12 | spec.homepage = "https://github.com/piotrmurach/strings-truncation" 13 | spec.license = "MIT" 14 | 15 | if spec.respond_to?(:metadata) 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | spec.metadata["bug_tracker_uri"] = "https://github.com/piotrmurach/strings-truncation/issues" 18 | spec.metadata["changelog_uri"] = "https://github.com/piotrmurach/strings-truncation/blob/master/CHANGELOG.md" 19 | spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/strings-truncation" 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["rubygems_mfa_required"] = "true" 22 | spec.metadata["source_code_uri"] = "https://github.com/piotrmurach/strings-truncation" 23 | end 24 | 25 | spec.files = Dir["lib/**/*"] 26 | spec.extra_rdoc_files = ["README.md", "CHANGELOG.md", "LICENSE.txt"] 27 | spec.require_paths = ["lib"] 28 | spec.required_ruby_version = ">= 2.0.0" 29 | 30 | spec.add_dependency "strings-ansi", "~> 0.2.0" 31 | spec.add_dependency "unicode-display_width", ">= 1.6", "< 3.0" 32 | 33 | spec.add_development_dependency "rake" 34 | spec.add_development_dependency "rspec", ">= 3.0" 35 | end 36 | -------------------------------------------------------------------------------- /spec/unit/configure_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Strings::Truncation, "#configure" do 4 | it "defaults configuration values" do 5 | strings = described_class.new 6 | 7 | expect(strings.configuration.length).to eq(30) 8 | expect(strings.configuration.omission).to eq("…") 9 | expect(strings.configuration.position).to eq(0) 10 | expect(strings.configuration.separator).to eq(nil) 11 | end 12 | 13 | it "configures settings at initialisation" do 14 | strings = described_class.new( 15 | length: 25, 16 | omission: "[...]", 17 | position: :start, 18 | separator: /[, ]/ 19 | ) 20 | 21 | expect(strings.configuration.length).to eq(25) 22 | expect(strings.configuration.omission).to eq("[...]") 23 | expect(strings.configuration.position).to eq(:start) 24 | expect(strings.configuration.separator).to eq(/[, ]/) 25 | 26 | text = "I try all things, I achieve what I can." 27 | 28 | expect(strings.truncate(text)).to eq("[...]achieve what I can.") 29 | end 30 | 31 | it "configures settings at runtime using keyword arguments" do 32 | strings = described_class.new 33 | 34 | strings.configure(length: 25, omission: "[...]", position: :start, 35 | separator: /[, ]/) 36 | 37 | expect(strings.configuration.length).to eq(25) 38 | expect(strings.configuration.omission).to eq("[...]") 39 | expect(strings.configuration.position).to eq(:start) 40 | expect(strings.configuration.separator).to eq(/[, ]/) 41 | 42 | text = "I try all things, I achieve what I can." 43 | 44 | expect(strings.truncate(text)).to eq("[...]achieve what I can.") 45 | end 46 | 47 | it "configures settings at runtime using a block" do 48 | strings = described_class.new 49 | 50 | strings.configure do |config| 51 | config.length 25 52 | config.omission "[...]" 53 | config.position :start 54 | config.separator(/[, ]/) 55 | end 56 | 57 | expect(strings.configuration.length).to eq(25) 58 | expect(strings.configuration.omission).to eq("[...]") 59 | expect(strings.configuration.position).to eq(:start) 60 | expect(strings.configuration.separator).to eq(/[, ]/) 61 | 62 | text = "I try all things, I achieve what I can." 63 | 64 | expect(strings.truncate(text)).to eq("[...]achieve what I can.") 65 | end 66 | 67 | it "overrides configuration on a method call" do 68 | strings = described_class.new 69 | 70 | strings.configure do |config| 71 | config.length 25 72 | config.omission "[...]" 73 | config.position :start 74 | config.separator(/[, ]/) 75 | end 76 | 77 | text = "I try all things, I achieve what I can." 78 | 79 | expect(strings.truncate(text, length: 20, omission: "...", 80 | position: :middle, separator: nil)) 81 | .to eq("I try all...t I can.") 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/strings/truncation/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Strings 4 | class Truncation 5 | # A configuration object used by a Strings::Truncation instance 6 | # 7 | # @api private 8 | class Configuration 9 | DEFAULT_LENGTH = 30 10 | 11 | DEFAULT_OMISSION = "…" 12 | 13 | DEFAULT_POSITION = 0 14 | 15 | # Create a configuration 16 | # 17 | # @api public 18 | def initialize(length: DEFAULT_LENGTH, omission: DEFAULT_OMISSION, 19 | position: DEFAULT_POSITION, separator: nil) 20 | @length = length 21 | @omission = omission 22 | @position = position 23 | @separator = separator 24 | end 25 | 26 | # Update current configuration 27 | # 28 | # @api public 29 | def update(length: nil, omission: nil, position: nil, separator: nil) 30 | @length = length if length 31 | @omission = omission if omission 32 | @position = position if position 33 | @separator = separator if separator 34 | end 35 | 36 | # The maximum length to truncate to 37 | # 38 | # @example 39 | # strings = Strings::Truncation.new 40 | # 41 | # strings.configure do |config| 42 | # config.length 40 43 | # end 44 | # 45 | # @param [Integer] number 46 | # 47 | # @api public 48 | def length(number = (not_set = true)) 49 | if not_set 50 | @length 51 | else 52 | @length = number 53 | end 54 | end 55 | 56 | # The string to denote omitted content 57 | # 58 | # @example 59 | # strings = Strings::Truncation.new 60 | # 61 | # strings.configure do |config| 62 | # config.omission "..." 63 | # end 64 | # 65 | # @param [String] string 66 | # 67 | # @api public 68 | def omission(string = (not_set = true)) 69 | if not_set 70 | @omission 71 | else 72 | @omission = string 73 | end 74 | end 75 | 76 | # The position of the omission within the string 77 | # 78 | # @example 79 | # strings = Strings::Truncation.new 80 | # 81 | # strings.configure do |config| 82 | # config.position :start 83 | # end 84 | # 85 | # 86 | # @param [Symbol] position 87 | # the position out of :start, :middle or :end 88 | # 89 | # @api public 90 | def position(position = (not_set = true)) 91 | if not_set 92 | @position 93 | else 94 | @position = position 95 | end 96 | end 97 | 98 | # The separator to break the characters into words 99 | # 100 | # @example 101 | # strings = Strings::Truncation.new 102 | # 103 | # strings.configure do |config| 104 | # config.separator /[, ]/ 105 | # end 106 | # 107 | # @param [String|Regexp] separator 108 | # 109 | # @api public 110 | def separator(separator = (not_set = true)) 111 | if not_set 112 | @separator 113 | else 114 | @separator = separator 115 | end 116 | end 117 | end # Configruation 118 | end # Truncation 119 | end # Strings 120 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | piotr@piotrmurach.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | strings logo 3 |
4 | 5 | # Strings::Truncation 6 | 7 | [![Gem Version](https://badge.fury.io/rb/strings-truncation.svg)][gem] 8 | [![Actions CI](https://github.com/piotrmurach/strings-truncation/actions/workflows/ci.yml/badge.svg)][gh_actions_ci] 9 | [![Build status](https://ci.appveyor.com/api/projects/status/s8y94c4tvi8mgrh2?svg=true)][appveyor] 10 | [![Maintainability](https://api.codeclimate.com/v1/badges/f7ecb5bf87696e522ccb/maintainability)][codeclimate] 11 | [![Coverage Status](https://coveralls.io/repos/github/piotrmurach/strings-truncation/badge.svg?branch=master)][coverage] 12 | 13 | [gem]: http://badge.fury.io/rb/strings-truncation 14 | [gh_actions_ci]: https://github.com/piotrmurach/strings-truncation/actions/workflows/ci.yml 15 | [appveyor]: https://ci.appveyor.com/project/piotrmurach/strings-truncation 16 | [codeclimate]: https://codeclimate.com/github/piotrmurach/strings-truncation/maintainability 17 | [coverage]: https://coveralls.io/github/piotrmurach/strings-truncation?branch=master 18 | 19 | > Truncate strings with fullwidth characters and ANSI codes. 20 | 21 | ## Features 22 | 23 | * No monkey-patching String class 24 | * Omit text from the start, middle, end or both ends 25 | * Account for fullwidth characters in encodings such as UTF-8, EUC-JP 26 | * Shorten text without whitespaces between words (Chinese, Japanese, Korean etc) 27 | * Preserve ANSI escape codes 28 | 29 | ## Contents 30 | 31 | * [1. Usage](#1-usage) 32 | * [2. API](#2-api) 33 | * [2.1 configure](#21-configure) 34 | * [2.2 truncate](#22-truncate) 35 | * [3. Extending String class](#3-extending-string-class) 36 | 37 | ## Installation 38 | 39 | Add this line to your application's Gemfile: 40 | 41 | ```ruby 42 | gem "strings-truncation" 43 | ``` 44 | 45 | And then execute: 46 | 47 | $ bundle 48 | 49 | Or install it yourself as: 50 | 51 | $ gem install strings-truncation 52 | 53 | ## 1. Usage 54 | 55 | Use `truncate` to shorten string to 30 characters by default: 56 | 57 | ```ruby 58 | strings = Strings::Truncation.new 59 | strings.truncate("I try all things, I achieve what I can.") 60 | # => "I try all things, I achieve w…" 61 | ``` 62 | 63 | As a convenience, you can call `truncate` method directly on a class: 64 | 65 | ```ruby 66 | Strings::Truncation.truncate("I try all things, I achieve what I can.") 67 | # => "I try all things, I achieve w…" 68 | ``` 69 | 70 | To change the default truncation length, pass an integer as a second argument: 71 | 72 | ```ruby 73 | strings.truncate("I try all things, I achieve what I can.", 15) 74 | # => "I try all thin…" 75 | ``` 76 | 77 | Or if you want to be more explicit and flexible use `:length` keyword: 78 | 79 | ```ruby 80 | strings.truncate("I try all things, I achieve what I can.", length: 15) 81 | # => "I try all thin…" 82 | ``` 83 | 84 | You can specify custom omission string in place of default `…`: 85 | 86 | ```ruby 87 | strings.truncate("I try all things, I achieve what I can.", omission: "...") 88 | # => "I try all things, I achieve..." 89 | ``` 90 | 91 | If you wish to truncate preserving words use a string or regexp as a separator: 92 | 93 | ```ruby 94 | strings.truncate("I try all things, I achieve what I can.", separator: /\s/) 95 | # => "I try all things, I achieve…" 96 | ``` 97 | 98 | You can omit text from the `start`, `middle`, `end` or both `ends`: 99 | 100 | ```ruby 101 | strings.truncate("I try all things, I achieve what I can", position: :middle) 102 | # => "I try all thing…ve what I can." 103 | ``` 104 | 105 | You can truncate text with fullwidth characters (Chinese, Japanese, Korean etc): 106 | 107 | ```ruby 108 | strings.truncate("おはようございます", 8) 109 | # => "おはよ…" 110 | ``` 111 | 112 | As well as truncate text that contains ANSI escape codes: 113 | 114 | ```ruby 115 | strings.truncate("\e[34mI try all things, I achieve what I can\e[0m", 18) 116 | # => "\e[34mI try all things,\e[0m…" 117 | ``` 118 | 119 | ## 2. API 120 | 121 | ### 2.1 configure 122 | 123 | To change default configuration settings at initialization use keyword arguments. 124 | 125 | For example, to omit text from the start and separate on a whitespace character do: 126 | 127 | ```ruby 128 | strings = Strings::Truncation.new(position: :start, separator: /\s/) 129 | ``` 130 | 131 | After initialization, you can use `configure` to change settings inside a block: 132 | 133 | ```ruby 134 | strings.configure do |config| 135 | config.length 25 136 | config.omission "[...]" 137 | config.position :start 138 | config.separator /\s/ 139 | end 140 | ``` 141 | 142 | Alternatively, you can also use `configure` with keyword arguments: 143 | 144 | ```ruby 145 | strings.configure(position: :start, separator: /\s/) 146 | ``` 147 | 148 | ### 2.2 truncate 149 | 150 | By default a string is truncated from the end to maximum length of `30` display columns. 151 | 152 | ```ruby 153 | strings.truncate("I try all things, I achieve what I can.") 154 | # => "I try all things, I achieve w…" 155 | ``` 156 | 157 | To change the default truncation length, pass an integer as a second argument: 158 | 159 | ```ruby 160 | strings.truncate("I try all things, I achieve what I can.", 15) 161 | # => "I try all thin…" 162 | ``` 163 | 164 | Or use `:length` keyword to be more explicit: 165 | 166 | ```ruby 167 | strings.truncate("I try all things, I achieve what I can.", length: 15) 168 | # => "I try all thin…" 169 | ``` 170 | 171 | The default `…` omission character can be replaced using `:omission`: 172 | 173 | ```ruby 174 | strings.truncate("I try all things, I achieve what I can.", omission: "...") 175 | # => "I try all things, I achieve..." 176 | ``` 177 | 178 | You can omit text from the `start`, `middle`, `end` or both `ends` by specifying `:position`: 179 | 180 | ```ruby 181 | strings.truncate("I try all things, I achieve what I can", position: :start) 182 | # => "…things, I achieve what I can." 183 | 184 | strings.truncate("I try all things, I achieve what I can", position: :middle) 185 | # => "I try all thing…ve what I can." 186 | 187 | strings.truncate("I try all things, I achieve what I can", position: :ends) 188 | # => "… all things, I achieve what …" 189 | ``` 190 | 191 | To truncate based on custom character(s) use `:separator` that accepts a string or regular expression: 192 | 193 | ```ruby 194 | strings.truncate("I try all things, I achieve what I can.", separator: /\s/) 195 | => "I try all things, I achieve…" 196 | ``` 197 | 198 | You can combine all settings to achieve desired result: 199 | 200 | ```ruby 201 | strings.truncate("I try all things, I achieve what I can.", length: 20, 202 | omission: "...", position: :ends, separator: /\s/) 203 | # => "...I achieve what..." 204 | ``` 205 | 206 | ## 3. Extending String class 207 | 208 | Though it is highly discouraged to pollute core Ruby classes, you can add the required methods to String class by using refinements. 209 | 210 | To include all the **Strings::Truncation** methods, you can load extensions like so: 211 | 212 | ```ruby 213 | require "strings/truncation/extensions" 214 | 215 | using Strings::Truncation::Extensions 216 | ``` 217 | 218 | And then call `truncate` directly on any string: 219 | 220 | ```ruby 221 | "I try all things, I achieve what I can.".truncate(20, separator: " ") 222 | # => "I try all things, I…" 223 | ``` 224 | 225 | ## Development 226 | 227 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 228 | 229 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 230 | 231 | ## Contributing 232 | 233 | Bug reports and pull requests are welcome on GitHub at https://github.com/piotrmurach/strings-truncation. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 234 | 235 | ## License 236 | 237 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 238 | 239 | ## Code of Conduct 240 | 241 | Everyone interacting in the Strings::Truncation project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/piotrmurach/strings-truncation/blob/master/CODE_OF_CONDUCT.md). 242 | 243 | ## Copyright 244 | 245 | Copyright (c) 2019 Piotr Murach. See LICENSE for further details. 246 | -------------------------------------------------------------------------------- /spec/unit/truncate_multibyte_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Strings::Truncation, "truncate multibyte" do 4 | context "when two characters display length and without space" do 5 | let(:text) { "ラドクリフ、マラソン五輪代表に1万出場にも含み" } 6 | 7 | it "doesn't truncate string matching length" do 8 | text = "こんにちは" 9 | expect(Strings::Truncation.truncate(text, 10)).to eq(text) 10 | end 11 | 12 | it "truncates text and displays omission" do 13 | expect(Strings::Truncation.truncate(text, 12)).to eq("ラドクリフ…") 14 | end 15 | 16 | it "estimates total multibyte text width correctly " do 17 | text = "太丸ゴシック体" 18 | expect(Strings::Truncation.truncate(text, 8)).to eq("太丸ゴ…") 19 | end 20 | 21 | it "truncates multibyte text with string separator" do 22 | truncation = Strings::Truncation.truncate(text, 12, separator: "、") 23 | expect(truncation).to eq("ラドクリフ…") 24 | end 25 | 26 | it "truncates multibyte text with regex separator" do 27 | truncation = Strings::Truncation.truncate(text, 12, separator: /、/) 28 | expect(truncation).to eq("ラドクリフ…") 29 | end 30 | 31 | it "truncates multibyte text with custom omission" do 32 | omission = "... (see more)" 33 | truncation = Strings::Truncation.truncate(text, 20, omission: omission) 34 | 35 | expect(truncation).to eq("ラドク#{omission}") 36 | end 37 | 38 | context "from the start" do 39 | [ 40 | ["ありがとう", "", 0], 41 | ["ありがとう", "…", 1], 42 | ["ありがとう", "…", 2], 43 | ["ありがとう", "…とう", 5], 44 | ["ありがとう", "…とう", 6], 45 | ["ありがとう", "ありがとう", 10], 46 | ["ありがとう", "...とう", 7, { omission: "..." }], 47 | ["ありがとう", "...とう", 8, { omission: "..." }], 48 | ["ありがとう!", "…!", 2], 49 | ["ありがとう!", "…う!", 5], 50 | ["ありがとう!", "…とう!", 6], 51 | ["ありがとう!", "ありがとう!", 11], 52 | ["ありがとう!", "...う!", 7, { omission: "..." }], 53 | ["ありがとう!", "...とう!", 8, { omission: "..." }], 54 | ["あり がと う", "…", 1, { separator: " " }], 55 | ["あり がと う", "…", 2, { separator: " " }], 56 | ["あり がと う", "…う", 3, { separator: " " }], 57 | ["あり がと う", "…う", 6, { separator: " " }], 58 | ["あり がと う", "…がと う", 8, { separator: " " }], 59 | ["あり がと う", "…がと う", 9, { separator: " " }], 60 | ["あり がと う", "…がと う", 10, { separator: " " }], 61 | ["あり がと う", "…がと う", 11, { separator: " " }], 62 | ["あり がと う", "あり がと う", 12, { separator: " " }] 63 | ].each do |text, truncated, length, options = {}| 64 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 65 | strings = Strings::Truncation.new 66 | options.update(position: :start) 67 | expect(strings.truncate(text, length, **options)).to eq(truncated) 68 | end 69 | end 70 | end 71 | 72 | context "from the end" do 73 | [ 74 | ["ありがとう", "", 0], 75 | ["ありがとう", "…", 1], 76 | ["ありがとう", "…", 2], 77 | ["ありがとう", "あり…", 5], 78 | ["ありがとう", "あり…", 6], 79 | ["ありがとう", "ありがとう", 10], 80 | ["ありがとう", "あり...", 7, { omission: "..." }], 81 | ["ありがとう", "あり...", 8, { omission: "..." }], 82 | ["*ありがとう", "*…", 2], 83 | ["*ありがとう", "*あ…", 5], 84 | ["*ありがとう", "*あり…", 6], 85 | ["*ありがとう", "*ありがとう", 11], 86 | ["*ありがとう", "*あ...", 7, { omission: "..." }], 87 | ["*ありがとう", "*あり...", 8, { omission: "..." }], 88 | ["あり がと う", "…", 1, { separator: " " }], 89 | ["あり がと う", "…", 2, { separator: " " }], 90 | ["あり がと う", "…", 3, { separator: " " }], 91 | ["あり がと う", "あり…", 6, { separator: " " }], 92 | ["あり がと う", "あり…", 8, { separator: " " }] 93 | ].each do |text, truncated, length, options = {}| 94 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 95 | strings = Strings::Truncation.new 96 | options.update(position: :end) 97 | expect(strings.truncate(text, length, **options)).to eq(truncated) 98 | end 99 | end 100 | end 101 | 102 | context "from both ends" do 103 | [ 104 | ["ありがとう", "", 0], 105 | ["ありがとう", "…", 1], 106 | ["ありがとう", "…", 2], 107 | ["ありがとう", "…が…", 5], 108 | ["ありがとう", "…がと…", 6], 109 | ["ありがとう", "…りが…", 7], 110 | ["ありがとう", "…りがと…", 8], 111 | ["ありがとう", "…りがとう", 9], 112 | ["ありがとう", "ありがとう", 10], 113 | ["ありがとう", "...", 7, { omission: "..." }], 114 | ["ありがとう", "...が...", 8, { omission: "..." }], 115 | ["ありがとう!", "…", 2], 116 | ["ありがとう!", "…が…", 5], 117 | ["ありがとう!", "…がと…", 6], 118 | ["ありがとう!", "…りがと…", 8], 119 | ["ありがとう!", "…りがとう!", 10], 120 | ["ありがとう!", "ありがとう!", 11], 121 | ["ありがとう!", "..が..", 7, { omission: ".." }], 122 | ["ありがとう!", "..がと..", 8, { omission: ".." }], 123 | ["あり がと う", "…", 1, { separator: " " }], 124 | ["あり がと う", "…", 2, { separator: " " }], 125 | ["あり がと う", "…がと…", 6, { separator: " " }], 126 | ["あり がと う", "…がと …", 7, { separator: " " }], 127 | ["あり がと う", "…がと う", 8, { separator: " " }], 128 | ["あり がと う", "…がと う", 9, { separator: " " }] 129 | ].each do |text, truncated, length, options = {}| 130 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 131 | strings = Strings::Truncation.new 132 | options.update(position: :ends) 133 | expect(strings.truncate(text, length, **options)).to eq(truncated) 134 | end 135 | end 136 | end 137 | 138 | context "from the middle" do 139 | [ 140 | ["ありがとう", "", 0], 141 | ["ありがとう", "…", 1], 142 | ["ありがとう", "…", 2], 143 | ["ありがとう", "あ…う", 5], 144 | ["ありがとう", "あ…う", 6], 145 | ["ありがとう", "ありがとう", 10], 146 | ["ありがとう", "あ...う", 7, { omission: "..." }], 147 | ["ありがとう", "あ...う", 8, { omission: "..." }], 148 | ["ありがとう!", "…", 2], 149 | ["ありがとう!", "あ…!", 5], 150 | ["ありがとう!", "あ…!", 6], 151 | ["ありがとう!", "ありがとう!", 11], 152 | ["ありがとう!", "あ...!", 7, { omission: "..." }], 153 | ["ありがとう!", "あ...!", 8, { omission: "..." }], 154 | ["あり がと う", "…", 1, { separator: " " }], 155 | ["あり がと う", "…", 2, { separator: " " }], 156 | ["あり がと う", "…", 3, { separator: " " }], 157 | ["あり がと う", "…う", 6, { separator: " " }], 158 | ["あり がと う", "あり…う", 8, { separator: " " }] 159 | ].each do |text, truncated, length, options = {}| 160 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 161 | strings = Strings::Truncation.new 162 | options.update(position: :middle) 163 | expect(strings.truncate(text, length, **options)).to eq(truncated) 164 | end 165 | end 166 | end 167 | end 168 | 169 | context "when one character display length with space" do 170 | let(:text) { "Я пробую все, добиваюсь того, что могу" } 171 | 172 | it "doesn't truncate string matching length" do 173 | text = "Здравствуйте" 174 | expect(Strings::Truncation.truncate(text, 12)).to eq(text) 175 | end 176 | 177 | it "truncates 1 character long multibyte with spaces correctly" do 178 | truncation = Strings::Truncation.truncate(text, 20) 179 | 180 | expect(truncation).to eq("Я пробую все, добив…") 181 | end 182 | 183 | it "truncates Unicode with separator" do 184 | truncation = Strings::Truncation.truncate(text, 20, separator: " ") 185 | 186 | expect(truncation).to eq("Я пробую все,…") 187 | end 188 | 189 | context "from the start" do 190 | [ 191 | ["Здравствуйте", "", 0], 192 | ["Здравствуйте", "…", 1], 193 | ["Здравствуйте", "…е", 2], 194 | ["Здравствуйте", "…уйте", 5], 195 | ["Здравствуйте", "…вуйте", 6], 196 | ["Здравствуйте", "Здравствуйте", 12], 197 | ["Здравствуйте", "...уйте", 7, { omission: "..." }], 198 | ["Здравствуйте", "...вуйте", 8, { omission: "..." }], 199 | ["Здравствуйте!", "…!", 2], 200 | ["Здравствуйте!", "…йте!", 5], 201 | ["Здравствуйте!", "…уйте!", 6], 202 | ["Здравствуйте!", "Здравствуйте!", 13], 203 | ["Здравствуйте!", "...йте!", 7, { omission: "..." }], 204 | ["Здравствуйте!", "...уйте!", 8, { omission: "..." }] 205 | ].each do |text, truncated, length, options = {}| 206 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 207 | strings = Strings::Truncation.new 208 | options.update(position: :start) 209 | expect(strings.truncate(text, length, **options)).to eq(truncated) 210 | end 211 | end 212 | end 213 | 214 | context "from the middle" do 215 | [ 216 | ["Здравствуйте", "", 0], 217 | ["Здравствуйте", "…", 1], 218 | ["Здравствуйте", "З…", 2], 219 | ["Здравствуйте", "Зд…те", 5], 220 | ["Здравствуйте", "Здр…те", 6], 221 | ["Здравствуйте", "Здравствуйте", 12], 222 | ["Здравствуйте", "Зд...те", 7, { omission: "..." }], 223 | ["Здравствуйте", "Здр...те", 8, { omission: "..." }], 224 | ["Здравствуйте!", "З…", 2], 225 | ["Здравствуйте!", "Зд…е!", 5], 226 | ["Здравствуйте!", "Здр…е!", 6], 227 | ["Здравствуйте!", "Здравствуйте!", 13], 228 | ["Здравствуйте!", "Зд...е!", 7, { omission: "..." }], 229 | ["Здравствуйте!", "Здр...е!", 8, { omission: "..." }] 230 | ].each do |text, truncated, length, options = {}| 231 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 232 | strings = Strings::Truncation.new 233 | options.update(position: :middle) 234 | expect(strings.truncate(text, length, **options)).to eq(truncated) 235 | end 236 | end 237 | end 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /spec/unit/truncate_ansi_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Strings::Truncation, "truncates ansi" do 4 | it "doesn't truncate string matching length" do 5 | text = "\e[32mHello\e[0m\e[34m\e[0m" 6 | expect(Strings::Truncation.truncate(text, 5)).to eq(text) 7 | end 8 | 9 | it "truncates string with ANSI characters within boundary" do 10 | text = "I try \e[34mall things\e[0m, I achieve what I can" 11 | truncation = Strings::Truncation.truncate(text, 18) 12 | 13 | expect(truncation).to eq("I try \e[34mall things\e[0m,…") 14 | end 15 | 16 | it "adds ANSI reset for shorter string" do 17 | text = "I try \e[34mall things\e[0m, I achieve what I can" 18 | truncation = Strings::Truncation.truncate(text, 10) 19 | 20 | expect(truncation).to eq("I try \e[34mall\e[0m…") 21 | end 22 | 23 | it "correctly estimates width of strings with ANSI codes " do 24 | text = "I try \e[34mall things\e[0m, I achieve" 25 | 26 | expect(Strings::Truncation.truncate(text)) 27 | .to eq("I try \e[34mall things\e[0m, I achieve") 28 | end 29 | 30 | context "from the start" do 31 | [ 32 | ["\e[34maaaaabbbbb\e[0m", "", 0], 33 | ["\e[34maaaaabbbbb\e[0m", "…", 1], 34 | ["\e[34maaaaabbbbb\e[0m", "…\e[34mb\e[0m", 2], 35 | ["\e[34maaaaabbbbb\e[0m", "…\e[34mbbbb\e[0m", 5], 36 | ["\e[34maaaaabbbbb\e[0m", "…\e[34mbbbbb\e[0m", 6], 37 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaaabbbbb\e[0m", 10], 38 | ["\e[34maaaaabbbbb\e[0m", "...\e[34mbb\e[0m", 5, { omission: "..." }], 39 | ["\e[34maaaaabbbbb\e[0m", "...\e[34mbbb\e[0m", 6, { omission: "..." }], 40 | ["\e[34maaaaabbbbbc\e[0m", "…\e[34mc\e[0m", 2], 41 | ["\e[34maaaaabbbbbc\e[0m", "…\e[34mbbbc\e[0m", 5], 42 | ["\e[34maaaaabbbbbc\e[0m", "…\e[34mbbbbc\e[0m", 6], 43 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaaabbbbbc\e[0m", 11], 44 | ["\e[34maaaaabbbbbc\e[0m", "...\e[34mbc\e[0m", 5, { omission: "..." }], 45 | ["\e[34maaaaabbbbbc\e[0m", "...\e[34mbbc\e[0m", 6, { omission: "..." }], 46 | ["\e[34maaa bbb ccc\e[0m", "…", 1, { separator: " " }], 47 | ["\e[34maaa bbb ccc\e[0m", "…", 2, { separator: " " }], 48 | ["\e[34maaa bbb ccc\e[0m", "…", 3, { separator: " " }], 49 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mccc\e[0m", 4, { separator: " " }], 50 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mccc\e[0m", 5, { separator: " " }], 51 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mccc\e[0m", 6, { separator: " " }], 52 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mccc\e[0m", 7, { separator: " " }], 53 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb ccc\e[0m", 8, { separator: " " }], 54 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb ccc\e[0m", 9, { separator: " " }], 55 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb ccc\e[0m", 10, { separator: " " }], 56 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa bbb ccc\e[0m", 11, 57 | { separator: " " }] 58 | ].each do |text, truncated, length, options = {}| 59 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 60 | strings = Strings::Truncation.new 61 | options.update(position: :start) 62 | expect(strings.truncate(text, length, **options)).to eq(truncated) 63 | end 64 | end 65 | end 66 | 67 | context "from the end" do 68 | [ 69 | ["\e[34maaaaabbbbb\e[0m", "", 0], 70 | ["\e[34maaaaabbbbb\e[0m", "…", 1], 71 | ["\e[34maaaaabbbbb\e[0m", "\e[34ma\e[0m…", 2], 72 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaa\e[0m…", 5], 73 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaaa\e[0m…", 6], 74 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaaabbbbb\e[0m", 10], 75 | ["\e[34maaaaabbbbb\e[0m", "\e[34maa\e[0m...", 5, { omission: "..." }], 76 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaa\e[0m...", 6, { omission: "..." }], 77 | ["\e[34maaaaabbbbbc\e[0m", "\e[34ma\e[0m…", 2], 78 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaa\e[0m…", 5], 79 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaaa\e[0m…", 6], 80 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaaabbbbbc\e[0m", 11], 81 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maa\e[0m...", 5, { omission: "..." }], 82 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaa\e[0m...", 6, { omission: "..." }], 83 | ["\e[34maaa bbb ccc\e[0m", "…", 1, { separator: " " }], 84 | ["\e[34maaa bbb ccc\e[0m", "…", 2, { separator: " " }], 85 | ["\e[34maaa bbb ccc\e[0m", "…", 3, { separator: " " }], 86 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…", 4, { separator: " " }], 87 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…", 5, { separator: " " }], 88 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…", 6, { separator: " " }], 89 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…", 7, { separator: " " }], 90 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa bbb\e[0m…", 8, { separator: " " }] 91 | ].each do |text, truncated, length, options = {}| 92 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 93 | strings = Strings::Truncation.new 94 | options.update(position: :end) 95 | expect(strings.truncate(text, length, **options)).to eq(truncated) 96 | end 97 | end 98 | end 99 | 100 | context "from both ends" do 101 | [ 102 | ["\e[34maaaaabbbbb\e[0m", "", 0], 103 | ["\e[34maaaaabbbbb\e[0m", "…", 1], 104 | ["\e[34maaaaabbbbb\e[0m", "…", 2], 105 | ["\e[34maaaaabbbbb\e[0m", "…\e[34ma\e[0m…", 3], 106 | ["\e[34maaaaabbbbb\e[0m", "…\e[34mab\e[0m…", 4], 107 | ["\e[34maaaaabbbbb\e[0m", "…\e[34maab\e[0m…", 5], 108 | ["\e[34maaaaabbbbb\e[0m", "…\e[34maabb\e[0m…", 6], 109 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaaabbbbb\e[0m", 10], 110 | ["\e[34maaaaabbbbb\e[0m", "...", 5, { omission: "..." }], 111 | ["\e[34maaaaabbbbb\e[0m", "...\e[34ma\e[0m...", 7, { omission: "..." }], 112 | ["\e[34maaaaabbbbb\e[0m", "...\e[34mab\e[0m...", 8, { omission: "..." }], 113 | ["\e[34maaaaabbbbbc\e[0m", "…", 2], 114 | ["\e[34maaaaabbbbbc\e[0m", "…\e[34mabb\e[0m…", 5], 115 | ["\e[34maaaaabbbbbc\e[0m", "…\e[34maabb\e[0m…", 6], 116 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaaabbbbbc\e[0m", 11], 117 | ["\e[34maaaaabbbbbc\e[0m", "...", 5, { omission: "..." }], 118 | ["\e[34maaaaabbbbbc\e[0m", "...\e[34mb\e[0m...", 7, { omission: "..." }], 119 | ["\e[34maaaaabbbbbc\e[0m", "...\e[34mab\e[0m...", 8, { omission: "..." }], 120 | ["\e[34maaa bbb ccc\e[0m", "…", 1, { separator: " " }], 121 | ["\e[34maaa bbb ccc\e[0m", "…", 2, { separator: " " }], 122 | ["\e[34maaa bbb ccc\e[0m", "…", 3, { separator: " " }], 123 | ["\e[34maaa bbb ccc\e[0m", "…", 4, { separator: " " }], 124 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb\e[0m…", 5, { separator: " " }], 125 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb\e[0m…", 6, { separator: " " }], 126 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb\e[0m…", 7, { separator: " " }], 127 | ["\e[34maaa bbb ccc\e[0m", "…\e[34mbbb ccc\e[0m", 10, { separator: " " }] 128 | ].each do |text, truncated, length, options = {}| 129 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 130 | strings = Strings::Truncation.new 131 | options.update(position: :ends) 132 | expect(strings.truncate(text, length, **options)).to eq(truncated) 133 | end 134 | end 135 | end 136 | 137 | context "from the middle" do 138 | [ 139 | ["\e[34maaaaabbbbb\e[0m", "", 0], 140 | ["\e[34maaaaabbbbb\e[0m", "…", 1], 141 | ["\e[34maaaaabbbbb\e[0m", "\e[34ma\e[0m…", 2], 142 | ["\e[34maaaaabbbbb\e[0m", "\e[34ma\e[0m…\e[34mb\e[0m", 3], 143 | ["\e[34maaaaabbbbb\e[0m", "\e[34maa\e[0m…\e[34mb\e[0m", 4], 144 | ["\e[34maaaaabbbbb\e[0m", "\e[34maa\e[0m…\e[34mbb\e[0m", 5], 145 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaa\e[0m…\e[34mbb\e[0m", 6], 146 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaaaabbbbb\e[0m", 10], 147 | ["\e[34maaaaabbbbb\e[0m", "\e[34maa\e[0m...\e[34mbb\e[0m", 7, 148 | { omission: "..." }], 149 | ["\e[34maaaaabbbbb\e[0m", "\e[34maaa\e[0m...\e[34mbb\e[0m", 8, 150 | { omission: "..." }], 151 | ["\e[34maaaaabbbbbc\e[0m", "\e[34ma\e[0m…", 2], 152 | ["\e[34maaaaabbbbbc\e[0m", "\e[34ma\e[0m…\e[34mc\e[0m", 3], 153 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maa\e[0m…\e[34mc\e[0m", 4], 154 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maa\e[0m…\e[34mbc\e[0m", 5], 155 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaa\e[0m…\e[34mbc\e[0m", 6], 156 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaaaabbbbbc\e[0m", 11], 157 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maa\e[0m...\e[34mbc\e[0m", 7, 158 | { omission: "..." }], 159 | ["\e[34maaaaabbbbbc\e[0m", "\e[34maaa\e[0m...\e[34mbc\e[0m", 8, 160 | { omission: "..." }], 161 | ["\e[34maaa bbb ccc\e[0m", "…", 1, { separator: " " }], 162 | ["\e[34maaa bbb ccc\e[0m", "…", 2, { separator: " " }], 163 | ["\e[34maaa bbb ccc\e[0m", "…", 3, { separator: " " }], 164 | ["\e[34maaa bbb ccc\e[0m", "…", 4, { separator: " " }], 165 | ["\e[34maaa bbb ccc\e[0m", "…", 5, { separator: " " }], 166 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…", 6, { separator: " " }], 167 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…\e[34mccc\e[0m", 7, 168 | { separator: " " }], 169 | ["\e[34maaa bbb ccc\e[0m", "\e[34maaa\e[0m…\e[34mccc\e[0m", 8, 170 | { separator: " " }] 171 | ].each do |text, truncated, length, options = {}| 172 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 173 | strings = Strings::Truncation.new 174 | options.update(position: :middle) 175 | expect(strings.truncate(text, length, **options)).to eq(truncated) 176 | end 177 | end 178 | 179 | it "truncates text with ANSI in the middle" do 180 | text = "aaaaabbbbb\e[34mccccc\e[0mddddd" 181 | truncation = Strings::Truncation.truncate(text, 14, position: :middle) 182 | 183 | expect(truncation).to eq("aaaaabb…\e[34mc\e[0mddddd") 184 | end 185 | 186 | it "truncates text from the middle" do 187 | text = "\e[34mIt is not down on any map; true places never are.\e[0m" 188 | truncation = Strings::Truncation.truncate(text, 20, position: :middle) 189 | 190 | expect(truncation).to eq("\e[34mIt is not \e[0m…\e[34mever are.\e[0m") 191 | end 192 | 193 | it "truncates text from the middle with a long omission" do 194 | text = "\e[34mIt is not down on any map; true places never are.\e[0m" 195 | truncation = Strings::Truncation.truncate(text, 35, position: :middle, 196 | omission: "[...]") 197 | 198 | expect(truncation) 199 | .to eq("\e[34mIt is not down \e[0m[...]\e[34maces never are.\e[0m") 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/strings/truncation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "strscan" 5 | require "strings-ansi" 6 | require "unicode/display_width" 7 | 8 | require_relative "truncation/configuration" 9 | require_relative "truncation/version" 10 | 11 | module Strings 12 | class Truncation 13 | class Error < StandardError; end 14 | 15 | ANSI_REGEXP = /#{Strings::ANSI::ANSI_MATCHER}/.freeze 16 | RESET_REGEXP = /#{Regexp.escape(Strings::ANSI::RESET)}/.freeze 17 | END_REGEXP = /\A(#{Strings::ANSI::ANSI_MATCHER})*\z/.freeze 18 | 19 | # Global instance 20 | # 21 | # @api private 22 | def self.__instance__ 23 | @__instance__ ||= Truncation.new 24 | end 25 | 26 | class << self 27 | extend Forwardable 28 | 29 | delegate %i[truncate] => :__instance__ 30 | end 31 | 32 | # Create a Strings::Truncation instance 33 | # 34 | # @example 35 | # strings = Strings::Truncation.new(separator: /[,- ]/) 36 | # 37 | # @param [Integer] length 38 | # the maximum length to truncate to 39 | # @param [String] omission 40 | # the string to denote omitted content 41 | # @param [String|Integer] position 42 | # the position of the omission within the string 43 | # @param [String|Regexp] separator 44 | # the separator to break words on 45 | # 46 | # @api public 47 | def initialize(**options) 48 | configuration.update(**options) 49 | end 50 | 51 | # Access configuration 52 | # 53 | # @api public 54 | def configuration 55 | @configuration ||= Configuration.new 56 | end 57 | 58 | # Configure truncation 59 | # 60 | # @example 61 | # strings = Strings::Truncation.new 62 | # strings.configure do |config| 63 | # config.length 20 64 | # config.omission "[...]" 65 | # config.position :middle 66 | # config.separator /[,- ]/ 67 | # end 68 | # 69 | # @example 70 | # strings = Strings::Truncation.new 71 | # strings.configure length: 20, omission: "[...]" 72 | # 73 | # @yield [Configuration] 74 | # 75 | # @api public 76 | def configure(**options) 77 | if block_given? 78 | yield configuration 79 | else 80 | configuration.update(**options) 81 | end 82 | end 83 | 84 | # Truncate a text at a given length (defualts to 30) 85 | # 86 | # @param [String] text 87 | # the text to be truncated 88 | # 89 | # @param [Integer] truncate_at 90 | # the width at which to truncate the text 91 | # 92 | # @param [String|Regexp] separator 93 | # the character for splitting words 94 | # 95 | # @param [String] omission 96 | # the string to use for displaying omitted content 97 | # 98 | # @param [String|Integer] position 99 | # the position in text from which to omit content 100 | # 101 | # @example 102 | # truncate("It is not down on any map; true places never are.") 103 | # # => "It is not down on any map; tr…"" 104 | # 105 | # truncate("It is not down on any map; true places never are.", 15) 106 | # # => "It is not down…"" 107 | # 108 | # truncate("It is not down on any map; true places never are.", 109 | # separator: " ") 110 | # # => "It is not down on any map;…" 111 | # 112 | # truncate("It is not down on any map; true places never are.", 40, 113 | # omission: "[...]") 114 | # # => "It is not down on any map; true pla[...]" 115 | # 116 | # @api public 117 | def truncate(text, truncate_at = configuration.length, length: nil, 118 | position: configuration.position, 119 | separator: configuration.separator, 120 | omission: configuration.omission) 121 | truncate_at = length if length 122 | 123 | return text if truncate_at.nil? || text.bytesize <= truncate_at.to_i 124 | 125 | return "" if truncate_at.zero? 126 | 127 | separator = Regexp.new(separator) if separator 128 | 129 | case position 130 | when :start 131 | truncate_start(text, truncate_at, omission, separator) 132 | when :middle 133 | truncate_middle(text, truncate_at, omission, separator) 134 | when :ends 135 | truncate_ends(text, truncate_at, omission, separator) 136 | when :end 137 | truncate_from(0, text, truncate_at, omission, separator) 138 | when Numeric 139 | truncate_from(position, text, truncate_at, omission, separator) 140 | else 141 | raise Error, "unsupported position: #{position.inspect}" 142 | end 143 | end 144 | 145 | private 146 | 147 | # Truncate text at the start 148 | # 149 | # @param [String] text 150 | # the text to truncate 151 | # @param [Integer] length 152 | # the maximum length to truncate at 153 | # @param [String] omission 154 | # the string to denote omitted content 155 | # @param [String|Regexp] separator 156 | # the pattern or string to separate on 157 | # 158 | # @return [String] 159 | # the truncated text 160 | # 161 | # @api private 162 | def truncate_start(text, length, omission, separator) 163 | text_width = display_width(Strings::ANSI.sanitize(text)) 164 | omission_width = display_width(omission) 165 | return text if text_width == length 166 | return omission if omission_width == length 167 | 168 | from = [text_width - length, 0].max 169 | from += omission_width if from > 0 170 | words, = *slice(text, from, length - omission_width, 171 | omission_width: omission_width, 172 | separator: separator) 173 | 174 | "#{omission if from > 0}#{words}" 175 | end 176 | 177 | # Truncate text before the from position and after the length 178 | # 179 | # @param [Integer] from 180 | # the position to start extracting from 181 | # @param [String] text 182 | # the text to truncate 183 | # @param [Integer] length 184 | # the maximum length to truncate at 185 | # @param [String] omission 186 | # the string to denote omitted content 187 | # @param [String|Regexp] separator 188 | # the pattern or string to separate on 189 | # 190 | # @return [String] 191 | # the truncated text 192 | # 193 | # @api private 194 | def truncate_from(from, text, length, omission, separator) 195 | omission_width = display_width(omission) 196 | length_without_omission = length - omission_width 197 | length_without_omission -= omission_width if from > 0 198 | words, stop = *slice(text, from, length_without_omission, 199 | omission_width: omission_width, 200 | separator: separator) 201 | 202 | "#{omission if from > 0}#{words}#{omission if stop}" 203 | end 204 | 205 | # Truncate text in the middle 206 | # 207 | # @param [String] text 208 | # the text to truncate 209 | # @param [Integer] length 210 | # the maximum length to truncate at 211 | # @param [String] omission 212 | # the string to denote omitted content 213 | # @param [String|Regexp] separator 214 | # the pattern or string to separate on 215 | # 216 | # @return [String] 217 | # the truncated text 218 | # 219 | # @api private 220 | def truncate_middle(text, length, omission, separator) 221 | text_width = display_width(Strings::ANSI.sanitize(text)) 222 | omission_width = display_width(omission) 223 | return text if text_width == length 224 | return omission if omission_width == length 225 | 226 | half_length = length / 2 227 | rem_length = half_length + length % 2 228 | half_omission = omission_width / 2 229 | rem_omission = half_omission + omission_width % 2 230 | 231 | before_words, = *slice(text, 0, half_length - half_omission, 232 | omission_width: half_omission, 233 | separator: separator) 234 | 235 | after_words, = *slice(text, text_width - rem_length + rem_omission, 236 | rem_length - rem_omission, 237 | omission_width: rem_omission, 238 | separator: separator) 239 | 240 | "#{before_words}#{omission}#{after_words}" 241 | end 242 | 243 | # Truncate text at both ends 244 | # 245 | # @param [String] text 246 | # the text to truncate 247 | # @param [Integer] length 248 | # the maximum length to truncate at 249 | # @param [String] omission 250 | # the string to denote omitted content 251 | # @param [String|Regexp] separator 252 | # the pattern or string to separate on 253 | # 254 | # @return [String] 255 | # the truncated text 256 | # 257 | # @api private 258 | def truncate_ends(text, length, omission, separator) 259 | text_width = display_width(Strings::ANSI.sanitize(text)) 260 | omission_width = display_width(omission) 261 | return text if text_width <= length 262 | return omission if length <= 2 * omission_width 263 | 264 | from = (text_width - length) / 2 + omission_width 265 | words, stop = *slice(text, from, length - 2 * omission_width, 266 | omission_width: omission_width, 267 | separator: separator) 268 | return omission if words.empty? 269 | 270 | "#{omission if from > 0}#{words}#{omission if stop}" 271 | end 272 | 273 | # Extract number of characters from a text starting at the from position 274 | # 275 | # @param [Integer] from 276 | # the position to start from 277 | # @param [Integer] length 278 | # the number of characters to extract 279 | # @param [Integer] omission_width 280 | # the width of the omission 281 | # @param [String|Regexp] separator 282 | # the string or pattern to use for splitting words 283 | # 284 | # @return [Array] 285 | # return a substring and a stop flag 286 | # 287 | # @api private 288 | def slice(text, from, length, omission_width: 0, separator: nil) 289 | scanner = StringScanner.new(text) 290 | length_with_omission = length + omission_width 291 | current_length = 0 292 | start_position = 0 293 | ansi_reset = false 294 | visible_char = false 295 | word_break = false 296 | stop = false 297 | words = [] 298 | word = [] 299 | char = nil 300 | 301 | while !(scanner.eos? || stop) 302 | if scanner.scan(RESET_REGEXP) 303 | unless scanner.eos? 304 | words << scanner.matched 305 | ansi_reset = false 306 | end 307 | elsif scanner.scan(ANSI_REGEXP) 308 | words << scanner.matched 309 | ansi_reset = true 310 | else 311 | if (char =~ separator && start_position <= from) || 312 | separator && start_position.zero? 313 | word_break = start_position != from 314 | end 315 | 316 | char = scanner.getch 317 | char_width = display_width(char) 318 | start_position += char_width 319 | next if (start_position - char_width) < from 320 | 321 | current_length += char_width 322 | 323 | if char =~ separator 324 | if word_break 325 | word_break = false 326 | current_length = 0 327 | next 328 | end 329 | visible_char = true 330 | words << word.join 331 | word.clear 332 | end 333 | 334 | if current_length <= length || scanner.check(END_REGEXP) && 335 | current_length <= length_with_omission 336 | if separator 337 | word << char unless word_break 338 | else 339 | words << char 340 | visible_char = true 341 | end 342 | else 343 | stop = true 344 | end 345 | end 346 | end 347 | 348 | visible_char = true if !word.empty? && scanner.eos? 349 | 350 | return ["", stop] unless visible_char 351 | 352 | words << word.join if !word.empty? && scanner.eos? 353 | 354 | words << Strings::ANSI::RESET if ansi_reset 355 | 356 | [words.join, stop] 357 | end 358 | 359 | # Visible width of a string 360 | # 361 | # @return [Integer] 362 | # 363 | # @api private 364 | def display_width(string) 365 | Unicode::DisplayWidth.of(string) 366 | end 367 | end # Truncation 368 | end # Strings 369 | -------------------------------------------------------------------------------- /spec/unit/truncate_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Strings::Truncation, "#truncate" do 4 | it "doesn't truncate for nil length and returns the original text" do 5 | text = "It is not down on any map; true places never are." 6 | 7 | expect(Strings::Truncation.truncate(text, nil)).to eq(text) 8 | end 9 | 10 | it "truncates to length of 0 and returns an empty string" do 11 | text = "It is not down on any map; true places never are." 12 | 13 | expect(Strings::Truncation.truncate(text, 0)).to eq("") 14 | end 15 | 16 | it "truncates to length of 1 and returns only the omission character" do 17 | text = "It is not down on any map; true places never are." 18 | 19 | expect(Strings::Truncation.truncate(text, 1)).to eq("…") 20 | end 21 | 22 | it "doesn't change text for equal length" do 23 | text = "It is not down on any map; true places never are." 24 | truncation = Strings::Truncation.truncate(text, text.length) 25 | 26 | expect(truncation).to eq(text) 27 | end 28 | 29 | it "doesn't truncate text when length exceeds content" do 30 | text = "It is not down on any map; true places never are." 31 | 32 | expect(Strings::Truncation.truncate(text, 100)).to eq(text) 33 | end 34 | 35 | it "goes over default length of 30 characters" do 36 | text = "It is not down on any map; true places never are." 37 | 38 | expect(Strings::Truncation.truncate(text)).to eq("#{text[0..28]}…") 39 | end 40 | 41 | it "truncates whole words when separated used" do 42 | text = "It is not down on any map; true places never are." 43 | truncation = Strings::Truncation.truncate(text, separator: " ") 44 | 45 | expect(truncation).to eq("It is not down on any map;…") 46 | end 47 | 48 | it "truncates on word boundary" do 49 | text = "It is not down on any map; true places never are." 50 | truncation = Strings::Truncation.truncate(text, 21, separator: /\s/) 51 | 52 | expect(truncation).to eq("It is not down on…") 53 | end 54 | 55 | it "truncates using :length option" do 56 | text = "It is not down on any map; true places never are." 57 | truncation = Strings::Truncation.truncate(text, length: 21) 58 | 59 | expect(truncation).to eq("It is not down on an…") 60 | end 61 | 62 | it "calls truncate on an instance" do 63 | text = "It is not down on any map; true places never are." 64 | strings = Strings::Truncation.new 65 | truncation = strings.truncate(text, length: 21, separator: " ") 66 | 67 | expect(truncation).to eq("It is not down on…") 68 | end 69 | 70 | it "truncates with a custom omission" do 71 | text = "It is not down on any map; true places never are." 72 | truncation = Strings::Truncation.truncate(text, 40, omission: "[...]") 73 | 74 | expect(truncation).to eq("It is not down on any map; true pla[...]") 75 | end 76 | 77 | it "truncates with a custom omission and separator" do 78 | text = "It is not down on any map; true places never are." 79 | truncation = Strings::Truncation.truncate(text, 40, omission: "[...]", 80 | separator: " ") 81 | 82 | expect(truncation).to eq("It is not down on any map; true[...]") 83 | end 84 | 85 | it "truncates from an index" do 86 | text = "It is not down on any map; true places never are." 87 | truncation = Strings::Truncation.truncate(text, length: 21, position: 10) 88 | 89 | expect(truncation).to eq("…down on any map; tr…") 90 | end 91 | 92 | it "truncates text from the end" do 93 | text = "It is not down on any map; true places never are." 94 | truncation = Strings::Truncation.truncate(text, 20, position: :end) 95 | 96 | expect(truncation).to eq("It is not down on a…") 97 | end 98 | 99 | it "fails to recognize position parameter" do 100 | expect { 101 | Strings::Truncation.truncate("hello", 3, position: :unknown) 102 | }.to raise_error(Strings::Truncation::Error, 103 | "unsupported position: :unknown") 104 | end 105 | 106 | context "from the start" do 107 | [ 108 | ["aaaaabbbbb", "", 0], 109 | ["aaaaabbbbb", "…", 1], 110 | ["aaaaabbbbb", "…b", 2], 111 | ["aaaaabbbbb", "…bbbb", 5], 112 | ["aaaaabbbbb", "…bbbbb", 6], 113 | ["aaaaabbbbb", "aaaaabbbbb", 10], 114 | ["aaaaabbbbb", "...bb", 5, { omission: "..." }], 115 | ["aaaaabbbbb", "...bbb", 6, { omission: "..." }], 116 | ["aaaaabbbbbc", "…c", 2], 117 | ["aaaaabbbbbc", "…bbbc", 5], 118 | ["aaaaabbbbbc", "…bbbbc", 6], 119 | ["aaaaabbbbbc", "aaaaabbbbbc", 11], 120 | ["aaaaabbbbbc", "...bc", 5, { omission: "..." }], 121 | ["aaaaabbbbbc", "...bbc", 6, { omission: "..." }], 122 | ["aaa bbb ccc", "…", 1, { separator: " " }], 123 | ["aaa bbb ccc", "…", 2, { separator: " " }], 124 | ["aaa bbb ccc", "…", 3, { separator: " " }], 125 | ["aaa bbb ccc", "…ccc", 4, { separator: " " }], 126 | ["aaa bbb ccc", "…ccc", 5, { separator: " " }], 127 | ["aaa bbb ccc", "…ccc", 6, { separator: " " }], 128 | ["aaa bbb ccc", "…ccc", 7, { separator: " " }], 129 | ["aaa bbb ccc", "…bbb ccc", 8, { separator: " " }], 130 | ["aaa bbb ccc", "…bbb ccc", 9, { separator: " " }], 131 | ["aaa bbb ccc", "…bbb ccc", 10, { separator: " " }], 132 | ["aaa bbb ccc", "aaa bbb ccc", 11, { separator: " " }] 133 | ].each do |text, truncated, length, options = {}| 134 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 135 | strings = Strings::Truncation.new 136 | options.update(position: :start) 137 | expect(strings.truncate(text, length, **options)).to eq(truncated) 138 | end 139 | end 140 | 141 | it "truncates text at the start" do 142 | text = "It is not down on any map; true places never are." 143 | truncation = Strings::Truncation.truncate(text, 20, position: :start) 144 | 145 | expect(truncation).to eq("…e places never are.") 146 | end 147 | 148 | it "truncates text at the start with separator" do 149 | text = "It is not down on any map; true places never are." 150 | truncation = Strings::Truncation.truncate(text, 22, position: :start, 151 | separator: " ") 152 | 153 | expect(truncation).to eq("…places never are.") 154 | end 155 | end 156 | 157 | context "from the end" do 158 | [ 159 | ["aaaaabbbbb", "", 0], 160 | ["aaaaabbbbb", "…", 1], 161 | ["aaaaabbbbb", "a…", 2], 162 | ["aaaaabbbbb", "aaaa…", 5], 163 | ["aaaaabbbbb", "aaaaa…", 6], 164 | ["aaaaabbbbb", "aaaaabbbbb", 10], 165 | ["aaaaabbbbb", "aa...", 5, { omission: "..." }], 166 | ["aaaaabbbbb", "aaa...", 6, { omission: "..." }], 167 | ["aaaaabbbbbc", "a…", 2], 168 | ["aaaaabbbbbc", "aaaa…", 5], 169 | ["aaaaabbbbbc", "aaaaa…", 6], 170 | ["aaaaabbbbbc", "aaaaabbbbbc", 11], 171 | ["aaaaabbbbbc", "aa...", 5, { omission: "..." }], 172 | ["aaaaabbbbbc", "aaa...", 6, { omission: "..." }], 173 | ["aaa bbb ccc", "…", 1, { separator: " " }], 174 | ["aaa bbb ccc", "…", 2, { separator: " " }], 175 | ["aaa bbb ccc", "…", 3, { separator: " " }], 176 | ["aaa bbb ccc", "aaa…", 4, { separator: " " }], 177 | ["aaa bbb ccc", "aaa…", 5, { separator: " " }], 178 | ["aaa bbb ccc", "aaa…", 6, { separator: " " }], 179 | ["aaa bbb ccc", "aaa…", 7, { separator: " " }], 180 | ["aaa bbb ccc", "aaa bbb…", 8, { separator: " " }] 181 | ].each do |text, truncated, length, options = {}| 182 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 183 | strings = Strings::Truncation.new 184 | options.update(position: :end) 185 | expect(strings.truncate(text, length, **options)).to eq(truncated) 186 | end 187 | end 188 | end 189 | 190 | context "from both ends" do 191 | [ 192 | ["aaaaabbbbb", "", 0], 193 | ["aaaaabbbbb", "…", 1], 194 | ["aaaaabbbbb", "…", 2], 195 | ["aaaaabbbbb", "…a…", 3], 196 | ["aaaaabbbbb", "…ab…", 4], 197 | ["aaaaabbbbb", "…aab…", 5], 198 | ["aaaaabbbbb", "…aabb…", 6], 199 | ["aaaaabbbbb", "aaaaabbbbb", 10], 200 | ["aaaaabbbbb", "...", 5, { omission: "..." }], 201 | ["aaaaabbbbb", "...a...", 7, { omission: "..." }], 202 | ["aaaaabbbbb", "...ab...", 8, { omission: "..." }], 203 | ["aaaaabbbbbc", "…", 2], 204 | ["aaaaabbbbbc", "…b…", 3], 205 | ["aaaaabbbbbc", "…ab…", 4], 206 | ["aaaaabbbbbc", "…abb…", 5], 207 | ["aaaaabbbbbc", "…aabb…", 6], 208 | ["aaaaabbbbbc", "aaaaabbbbbc", 11], 209 | ["aaaaabbbbbc", "...", 5, { omission: "..." }], 210 | ["aaaaabbbbbc", "...b...", 7, { omission: "..." }], 211 | ["aaaaabbbbbc", "...ab...", 8, { omission: "..." }], 212 | ["aaa bbb ccc", "…", 1, { separator: " " }], 213 | ["aaa bbb ccc", "…", 2, { separator: " " }], 214 | ["aaa bbb ccc", "…", 3, { separator: " " }], 215 | ["aaa bbb ccc", "…", 4, { separator: " " }], 216 | ["aaa bbb ccc", "…bbb…", 5, { separator: " " }], 217 | ["aaa bbb ccc", "…bbb…", 6, { separator: " " }], 218 | ["aaa bbb ccc", "…bbb…", 7, { separator: " " }], 219 | ["aaa bbb ccc", "…bbb ccc", 8, { separator: " " }], 220 | ["aaa bbb ccc", "..bbb..", 9, { separator: " ", omission: ".." }], 221 | ["aaa bbb ccc", "..bbb ccc", 10, { separator: " ", omission: ".." }] 222 | ].each do |text, truncated, length, options = {}| 223 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 224 | strings = Strings::Truncation.new 225 | options.update(position: :ends) 226 | expect(strings.truncate(text, length, **options)).to eq(truncated) 227 | end 228 | end 229 | 230 | it "truncates text from both ends with long omission and separator" do 231 | text = "It is not down on any map; true places never are." 232 | truncation = Strings::Truncation.truncate(text, 35, position: :ends, 233 | omission: "...", 234 | separator: " ") 235 | 236 | expect(truncation).to eq("...down on any map; true places...") 237 | end 238 | end 239 | 240 | context "from the middle" do 241 | [ 242 | ["aaaaabbbbb", "", 0], 243 | ["aaaaabbbbb", "…", 1], 244 | ["aaaaabbbbb", "a…", 2], 245 | ["aaaaabbbbb", "a…b", 3], 246 | ["aaaaabbbbb", "aa…b", 4], 247 | ["aaaaabbbbb", "aa…bb", 5], 248 | ["aaaaabbbbb", "aaa…bb", 6], 249 | ["aaaaabbbbb", "aaaaabbbbb", 10], 250 | ["aaaaabbbbb", "aa...bb", 7, { omission: "..." }], 251 | ["aaaaabbbbb", "aaa...bb", 8, { omission: "..." }], 252 | ["aaaaabbbbbc", "a…", 2], 253 | ["aaaaabbbbbc", "a…c", 3], 254 | ["aaaaabbbbbc", "aa…c", 4], 255 | ["aaaaabbbbbc", "aa…bc", 5], 256 | ["aaaaabbbbbc", "aaa…bc", 6], 257 | ["aaaaabbbbbc", "aaaaabbbbbc", 11], 258 | ["aaaaabbbbbc", "aa...bc", 7, { omission: "..." }], 259 | ["aaaaabbbbbc", "aaa...bc", 8, { omission: "..." }], 260 | ["aaa bbb ccc", "…", 1, { separator: " " }], 261 | ["aaa bbb ccc", "…", 2, { separator: " " }], 262 | ["aaa bbb ccc", "…", 3, { separator: " " }], 263 | ["aaa bbb ccc", "…", 4, { separator: " " }], 264 | ["aaa bbb ccc", "…", 5, { separator: " " }], 265 | ["aaa bbb ccc", "aaa…", 6, { separator: " " }], 266 | ["aaa bbb ccc", "aaa…ccc", 7, { separator: " " }], 267 | ["aaa bbb ccc", "aaa…ccc", 8, { separator: " " }] 268 | ].each do |text, truncated, length, options = {}| 269 | it "truncates #{text.inspect} at #{length} -> #{truncated.inspect}" do 270 | strings = Strings::Truncation.new 271 | options.update(position: :middle) 272 | expect(strings.truncate(text, length, **options)).to eq(truncated) 273 | end 274 | end 275 | 276 | it "truncates text from the middle" do 277 | text = "It is not down on any map; true places never are." 278 | truncation = Strings::Truncation.truncate(text, 20, position: :middle) 279 | 280 | expect(truncation).to eq("It is not …ever are.") 281 | end 282 | 283 | it "truncates text from the middle with a long omission" do 284 | text = "It is not down on any map; true places never are." 285 | truncation = Strings::Truncation.truncate(text, 35, position: :middle, 286 | omission: "[...]") 287 | 288 | expect(truncation).to eq("It is not down [...]aces never are.") 289 | end 290 | 291 | it "truncates text from the middle with long omission and separator" do 292 | text = "It is not down on any map; true places never are." 293 | truncation = Strings::Truncation.truncate(text, 35, position: :middle, 294 | omission: "[...]", 295 | separator: " ") 296 | 297 | expect(truncation).to eq("It is not down[...]never are.") 298 | end 299 | end 300 | end 301 | --------------------------------------------------------------------------------