├── .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 |

3 |
4 |
5 | # Strings::Truncation
6 |
7 | [][gem]
8 | [][gh_actions_ci]
9 | [][appveyor]
10 | [][codeclimate]
11 | [][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 |
--------------------------------------------------------------------------------